diff --git a/.github/actions/bootstrap/action.yml b/.github/actions/bootstrap/action.yml new file mode 100644 index 00000000..b061361f --- /dev/null +++ b/.github/actions/bootstrap/action.yml @@ -0,0 +1,41 @@ +name: Bootstrap Workspace +description: > + Installs Flutter, activates melos and bootstraps the melos workspace, so every + package resolves its sibling packages from this repository instead of pub.dev. + +# NOTE: the calling job must check the repository out first — a local action +# cannot check out the repository it lives in. + +inputs: + flutter-channel: + description: The Flutter channel to install. + required: false + default: stable + +runs: + using: composite + steps: + - name: Install Flutter + uses: subosito/flutter-action@v2 + with: + cache: true + cache-key: flutter-:os:-:channel:-:version:-:arch:-:hash:-${{ hashFiles('**/pubspec.lock') }} + channel: ${{ inputs.flutter-channel }} + + - name: Install Melos + shell: bash + working-directory: ${{ github.workspace }} + run: flutter pub global activate melos + + # Bootstrapping is what writes the melos-managed `pubspec_overrides.yaml` + # files, which are gitignored. Without it, a plain `flutter pub get` in an + # example app resolves the very plugin it is supposed to be testing from + # pub.dev instead of from this checkout. + # + # Invoked through `pub global run` rather than as a bare `melos`, so it does + # not depend on the pub-cache bin directory being on PATH — that differs + # between the ubuntu, macos and windows runners this action is used from. + - name: Bootstrap Workspace + shell: bash + working-directory: ${{ github.workspace }} + run: flutter pub global run melos bootstrap diff --git a/.github/workflows/stream_thumbnail_native_builds.yml b/.github/workflows/stream_thumbnail_native_builds.yml new file mode 100644 index 00000000..b58debea --- /dev/null +++ b/.github/workflows/stream_thumbnail_native_builds.yml @@ -0,0 +1,145 @@ +name: stream_thumbnail_native_builds + +env: + FLUTTER_CHANNEL: stable + +on: + pull_request: + types: + - opened + - reopened + - synchronize + - ready_for_review + paths: + - 'packages/stream_thumbnail/**' + - '.github/actions/bootstrap/action.yml' + - '.github/workflows/stream_thumbnail_native_builds.yml' + push: + branches: + - main + paths: + - 'packages/stream_thumbnail/**' + - '.github/actions/bootstrap/action.yml' + - '.github/workflows/stream_thumbnail_native_builds.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: read + +jobs: + android: + timeout-minutes: 20 + runs-on: ubuntu-latest + defaults: + run: + working-directory: packages/stream_thumbnail/example + steps: + - name: Git Checkout + uses: actions/checkout@v6 + + - name: Bootstrap Workspace + uses: ./.github/actions/bootstrap + with: + flutter-channel: ${{ env.FLUTTER_CHANNEL }} + + - name: Build APK + run: flutter build apk --debug + + ios: + timeout-minutes: 20 + runs-on: macos-latest + defaults: + run: + working-directory: packages/stream_thumbnail/example + steps: + - name: Git Checkout + uses: actions/checkout@v6 + + - name: Bootstrap Workspace + uses: ./.github/actions/bootstrap + with: + flutter-channel: ${{ env.FLUTTER_CHANNEL }} + + - name: Build iOS (no codesign) + run: flutter build ios --debug --no-codesign + + macos: + timeout-minutes: 20 + runs-on: macos-latest + defaults: + run: + working-directory: packages/stream_thumbnail/example + steps: + - name: Git Checkout + uses: actions/checkout@v6 + + - name: Bootstrap Workspace + uses: ./.github/actions/bootstrap + with: + flutter-channel: ${{ env.FLUTTER_CHANNEL }} + + - name: Build macOS + run: flutter build macos --debug + + windows: + timeout-minutes: 20 + runs-on: windows-latest + defaults: + run: + working-directory: packages/stream_thumbnail/example + steps: + - name: Git Checkout + uses: actions/checkout@v6 + + - name: Bootstrap Workspace + uses: ./.github/actions/bootstrap + with: + flutter-channel: ${{ env.FLUTTER_CHANNEL }} + + - name: Build Windows + run: flutter build windows --debug + + linux: + timeout-minutes: 20 + runs-on: ubuntu-latest + defaults: + run: + working-directory: packages/stream_thumbnail/example + steps: + - name: Git Checkout + uses: actions/checkout@v6 + + - name: Install Linux Build Dependencies + run: | + sudo apt-get update + sudo apt-get install -y ninja-build libgtk-3-dev libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libwebp-dev + + - name: Bootstrap Workspace + uses: ./.github/actions/bootstrap + with: + flutter-channel: ${{ env.FLUTTER_CHANNEL }} + + - name: Build Linux + run: flutter build linux --debug + + web: + timeout-minutes: 20 + runs-on: ubuntu-latest + defaults: + run: + working-directory: packages/stream_thumbnail/example + steps: + - name: Git Checkout + uses: actions/checkout@v6 + + - name: Bootstrap Workspace + uses: ./.github/actions/bootstrap + with: + flutter-channel: ${{ env.FLUTTER_CHANNEL }} + + - name: Build Web + run: flutter build web diff --git a/melos.yaml b/melos.yaml index ab7c4346..1685949d 100644 --- a/melos.yaml +++ b/melos.yaml @@ -120,13 +120,26 @@ scripts: - Note: you can also rely on your IDEs Dart Analysis / Issues window. generate:all: - run: melos run generate:dart && melos run generate:flutter + run: melos run generate:dart && melos run generate:flutter && melos run generate:pigeon description: Build all generated files for Dart & Flutter packages in this project. generate:icons: run: melos exec -c 1 --file-exists="stream_icons.yaml" -- "dart run \$MELOS_ROOT_PATH/scripts/generate_icons.dart" description: Generate icon font and Dart classes from SVG files. + generate:pigeon: + run: | + melos exec -c 1 --file-exists="pigeons/messages.dart" -- " + dart run pigeon --input pigeons/messages.dart --swift_out=ios/\$MELOS_PACKAGE_NAME/Sources/\$MELOS_PACKAGE_NAME/Messages.g.swift && + dart run pigeon --input pigeons/messages.dart --swift_out=macos/\$MELOS_PACKAGE_NAME/Sources/\$MELOS_PACKAGE_NAME/Messages.g.swift && + dart format lib/src/messages.g.dart + " + description: | + Generate Pigeon platform-channel bindings for packages with a pigeons/messages.dart + schema. Swift output is generated once per Darwin platform (iOS, macOS) since SwiftPM + forbids a target's source path from escaping its own package root, so the two + platforms can't share one generated file in place. + check:barrels: run: melos exec -c 1 --file-exists="check_barrels.yaml" -- "dart run \$MELOS_ROOT_PATH/scripts/check_barrels.dart" description: Validate the public-barrel contract for packages with a check_barrels.yaml config. diff --git a/packages/stream_thumbnail/CHANGELOG.md b/packages/stream_thumbnail/CHANGELOG.md index 60732342..1aec9f43 100644 --- a/packages/stream_thumbnail/CHANGELOG.md +++ b/packages/stream_thumbnail/CHANGELOG.md @@ -1,3 +1,41 @@ +## Upcoming + +### ✨ Features + +- Added macOS support, sharing the same `AVAssetImageGenerator` + `libwebp` approach + as iOS. +- Added Windows support, via Media Foundation for decoding and WIC for JPEG/PNG + encoding. `StreamThumbnailFormat.webp` and `headers` (for authenticated remote + videos) are not yet supported on Windows. +- Added Linux support, via FFmpeg for decoding and jpeg/png encoding, and `libwebp` + for WebP — the only platform besides Android with full format support, including + `headers` for authenticated remote videos. + +### 💥 BREAKING CHANGES + +- `thumbnailFiles` now fails fast: if any video fails to produce a thumbnail, the call + throws instead of silently omitting that video from the returned list. +- `thumbnailFiles` now throws an `ArgumentError` when `thumbnailPath` names a file rather + than a directory and more than one video was given. That combination pointed every + video in the batch at the same output path. + +### 🐞 Fixed + +- Fixed a crash on iOS where a failed `thumbnailData` call returned `null` instead of an + error, causing the Dart side to crash casting `null` to `Uint8List`. +- Native errors now surface as typed `PlatformException`s on all platforms instead of a + generic `Exception` wrapping a raw Android stack trace. +- Unified the platform-channel implementation: Android previously acknowledged a call + immediately and delivered the real result via a separate reverse invocation; it now + replies once with the actual result, like iOS and web. + +### 🔧 Internal + +- Migrated the Android/iOS platform channel to Pigeon-generated, type-safe messaging + and rewrote the iOS plugin in Swift (previously Objective-C). The public Dart API is + unchanged; web keeps its separate hand-written implementation, since Pigeon does not + support it. + ## 0.1.0 * Initial release. diff --git a/packages/stream_thumbnail/README.md b/packages/stream_thumbnail/README.md index 0acbd9d9..0e385a07 100644 --- a/packages/stream_thumbnail/README.md +++ b/packages/stream_thumbnail/README.md @@ -2,7 +2,11 @@ ## 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. +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, macOS, Windows, Linux, and web. + +Windows requires the [Media Feature Pack](https://support.microsoft.com/en-us/topic/media-feature-pack-list-for-windows-n-editions-c1c6bfba-4bf7-4be6-ae13-8608318bf3d4) for decoding (present by default outside of "N"/"KN" Windows editions), and doesn't yet support `StreamThumbnailFormat.webp` or `headers` for authenticated remote videos. + +Linux requires FFmpeg and libwebp development packages on the build machine, e.g. on Debian/Ubuntu: `libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libwebp-dev`. ## Installation @@ -62,9 +66,9 @@ 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. | +| `headers` | HTTP headers sent when fetching a remote video. Not supported on Windows. | | `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`.| +| `imageFormat` | `JPEG`, `PNG`, or `WEBP`. Defaults to `PNG`. WebP on iOS/macOS is backed by `libwebp`; not yet supported on Windows.| | `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/src/main/kotlin/io/getstream/stream_thumbnail/Messages.g.kt b/packages/stream_thumbnail/android/src/main/kotlin/io/getstream/stream_thumbnail/Messages.g.kt new file mode 100644 index 00000000..be392e1b --- /dev/null +++ b/packages/stream_thumbnail/android/src/main/kotlin/io/getstream/stream_thumbnail/Messages.g.kt @@ -0,0 +1,369 @@ +// Autogenerated from Pigeon (v27.3.0), do not edit directly. +// See also: https://pub.dev/packages/pigeon +@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") + +package io.getstream.stream_thumbnail + +import android.util.Log +import io.flutter.plugin.common.BasicMessageChannel +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MessageCodec +import io.flutter.plugin.common.StandardMethodCodec +import io.flutter.plugin.common.StandardMessageCodec +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +private object MessagesPigeonUtils { + + fun wrapResult(result: Any?): List { + return listOf(result) + } + + fun wrapError(exception: Throwable): List { + return if (exception is FlutterError) { + listOf( + exception.code, + exception.message, + exception.details + ) + } else { + listOf( + exception.javaClass.simpleName, + exception.toString(), + "Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception) + ) + } + } + fun doubleEquals(a: Double, b: Double): Boolean { + // Normalize -0.0 to 0.0 and handle NaN equality. + return (if (a == 0.0) 0.0 else a) == (if (b == 0.0) 0.0 else b) || (a.isNaN() && b.isNaN()) + } + + fun floatEquals(a: Float, b: Float): Boolean { + // Normalize -0.0 to 0.0 and handle NaN equality. + return (if (a == 0.0f) 0.0f else a) == (if (b == 0.0f) 0.0f else b) || (a.isNaN() && b.isNaN()) + } + + fun doubleHash(d: Double): Int { + // Normalize -0.0 to 0.0 and handle NaN to ensure consistent hash codes. + val normalized = if (d == 0.0) 0.0 else d + val bits = java.lang.Double.doubleToLongBits(normalized) + return (bits xor (bits ushr 32)).toInt() + } + + fun floatHash(f: Float): Int { + // Normalize -0.0 to 0.0 and handle NaN to ensure consistent hash codes. + val normalized = if (f == 0.0f) 0.0f else f + return java.lang.Float.floatToIntBits(normalized) + } + + fun deepEquals(a: Any?, b: Any?): Boolean { + if (a === b) { + return true + } + if (a == null || b == null) { + return false + } + if (a is ByteArray && b is ByteArray) { + return a.contentEquals(b) + } + if (a is IntArray && b is IntArray) { + return a.contentEquals(b) + } + if (a is LongArray && b is LongArray) { + return a.contentEquals(b) + } + if (a is DoubleArray && b is DoubleArray) { + if (a.size != b.size) return false + for (i in a.indices) { + if (!doubleEquals(a[i], b[i])) return false + } + return true + } + if (a is FloatArray && b is FloatArray) { + if (a.size != b.size) return false + for (i in a.indices) { + if (!floatEquals(a[i], b[i])) return false + } + return true + } + if (a is Array<*> && b is Array<*>) { + if (a.size != b.size) return false + for (i in a.indices) { + if (!deepEquals(a[i], b[i])) return false + } + return true + } + if (a is List<*> && b is List<*>) { + if (a.size != b.size) return false + val iterA = a.iterator() + val iterB = b.iterator() + while (iterA.hasNext() && iterB.hasNext()) { + if (!deepEquals(iterA.next(), iterB.next())) return false + } + return true + } + if (a is Map<*, *> && b is Map<*, *>) { + if (a.size != b.size) return false + for (entry in a) { + val key = entry.key + var found = false + for (bEntry in b) { + if (deepEquals(key, bEntry.key)) { + if (deepEquals(entry.value, bEntry.value)) { + found = true + break + } else { + return false + } + } + } + if (!found) return false + } + return true + } + if (a is Double && b is Double) { + return doubleEquals(a, b) + } + if (a is Float && b is Float) { + return floatEquals(a, b) + } + return a == b + } + + fun deepHash(value: Any?): Int { + return when (value) { + null -> 0 + is ByteArray -> value.contentHashCode() + is IntArray -> value.contentHashCode() + is LongArray -> value.contentHashCode() + is DoubleArray -> { + var result = 1 + for (item in value) { + result = 31 * result + doubleHash(item) + } + result + } + is FloatArray -> { + var result = 1 + for (item in value) { + result = 31 * result + floatHash(item) + } + result + } + is Array<*> -> { + var result = 1 + for (item in value) { + result = 31 * result + deepHash(item) + } + result + } + is List<*> -> { + var result = 1 + for (item in value) { + result = 31 * result + deepHash(item) + } + result + } + is Map<*, *> -> { + var result = 0 + for (entry in value) { + result += ((deepHash(entry.key) * 31) xor deepHash(entry.value)) + } + result + } + is Double -> doubleHash(value) + is Float -> floatHash(value) + else -> value.hashCode() + } + } + +} + +/** + * Error class for passing custom error details to Flutter via a thrown PlatformException. + * @property code The error code. + * @property message The error message. + * @property details The error details. Must be a datatype supported by the api codec. + */ +class FlutterError ( + val code: String, + override val message: String? = null, + val details: Any? = null +) : RuntimeException() + +/** Wire representation of the image format for a generated thumbnail. */ +enum class ThumbnailFormat(val raw: Int) { + JPEG(0), + PNG(1), + WEBP(2); + + companion object { + fun ofRaw(raw: Int): ThumbnailFormat? { + return values().firstOrNull { it.raw == raw } + } + } +} + +/** + * A single thumbnail generation request sent to the native platform. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class ThumbnailRequest ( + val video: String, + val headers: Map? = null, + val thumbnailPath: String? = null, + val format: ThumbnailFormat, + val maxHeight: Long, + val maxWidth: Long, + val timeMs: Long, + val quality: Long +) + { + companion object { + fun fromList(pigeonVar_list: List): ThumbnailRequest { + val video = pigeonVar_list[0] as String + val headers = pigeonVar_list[1] as Map? + val thumbnailPath = pigeonVar_list[2] as String? + val format = pigeonVar_list[3] as ThumbnailFormat + val maxHeight = pigeonVar_list[4] as Long + val maxWidth = pigeonVar_list[5] as Long + val timeMs = pigeonVar_list[6] as Long + val quality = pigeonVar_list[7] as Long + return ThumbnailRequest(video, headers, thumbnailPath, format, maxHeight, maxWidth, timeMs, quality) + } + } + fun toList(): List { + return listOf( + video, + headers, + thumbnailPath, + format, + maxHeight, + maxWidth, + timeMs, + quality, + ) + } + override fun equals(other: Any?): Boolean { + if (other == null || other.javaClass != javaClass) { + return false + } + if (this === other) { + return true + } + val other = other as ThumbnailRequest + return MessagesPigeonUtils.deepEquals(this.video, other.video) && MessagesPigeonUtils.deepEquals(this.headers, other.headers) && MessagesPigeonUtils.deepEquals(this.thumbnailPath, other.thumbnailPath) && MessagesPigeonUtils.deepEquals(this.format, other.format) && MessagesPigeonUtils.deepEquals(this.maxHeight, other.maxHeight) && MessagesPigeonUtils.deepEquals(this.maxWidth, other.maxWidth) && MessagesPigeonUtils.deepEquals(this.timeMs, other.timeMs) && MessagesPigeonUtils.deepEquals(this.quality, other.quality) + } + + override fun hashCode(): Int { + var result = javaClass.hashCode() + result = 31 * result + MessagesPigeonUtils.deepHash(this.video) + result = 31 * result + MessagesPigeonUtils.deepHash(this.headers) + result = 31 * result + MessagesPigeonUtils.deepHash(this.thumbnailPath) + result = 31 * result + MessagesPigeonUtils.deepHash(this.format) + result = 31 * result + MessagesPigeonUtils.deepHash(this.maxHeight) + result = 31 * result + MessagesPigeonUtils.deepHash(this.maxWidth) + result = 31 * result + MessagesPigeonUtils.deepHash(this.timeMs) + result = 31 * result + MessagesPigeonUtils.deepHash(this.quality) + return result + } + override fun toString(): String { + return "ThumbnailRequest(video=$video, headers=$headers, thumbnailPath=$thumbnailPath, format=$format, maxHeight=$maxHeight, maxWidth=$maxWidth, timeMs=$timeMs, quality=$quality)" + } +} +private open class MessagesPigeonCodec : StandardMessageCodec() { + override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { + return when (type) { + 129.toByte() -> { + return (readValue(buffer) as Long?)?.let { + ThumbnailFormat.ofRaw(it.toInt()) + } + } + 130.toByte() -> { + return (readValue(buffer) as? List)?.let { + ThumbnailRequest.fromList(it) + } + } + else -> super.readValueOfType(type, buffer) + } + } + override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { + when (value) { + is ThumbnailFormat -> { + stream.write(129) + writeValue(stream, value.raw.toLong()) + } + is ThumbnailRequest -> { + stream.write(130) + writeValue(stream, value.toList()) + } + else -> super.writeValue(stream, value) + } + } +} + + +/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ +interface StreamThumbnailHostApi { + /** Generates a thumbnail for [ThumbnailRequest.video] and returns its bytes. */ + fun thumbnailData(request: ThumbnailRequest, callback: (Result) -> Unit) + /** + * Generates a thumbnail for [ThumbnailRequest.video] and returns the path + * it was written to. + */ + fun thumbnailFile(request: ThumbnailRequest, callback: (Result) -> Unit) + + companion object { + /** The codec used by StreamThumbnailHostApi. */ + val codec: MessageCodec by lazy { + MessagesPigeonCodec() + } + /** Sets up an instance of `StreamThumbnailHostApi` to handle messages through the `binaryMessenger`. */ + @JvmOverloads + fun setUp(binaryMessenger: BinaryMessenger, api: StreamThumbnailHostApi?, messageChannelSuffix: String = "") { + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.stream_thumbnail.StreamThumbnailHostApi.thumbnailData$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val requestArg = args[0] as ThumbnailRequest + api.thumbnailData(requestArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(MessagesPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(MessagesPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.stream_thumbnail.StreamThumbnailHostApi.thumbnailFile$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val requestArg = args[0] as ThumbnailRequest + api.thumbnailFile(requestArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(MessagesPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(MessagesPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} 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 index 4cc2306e..9f3883a6 100644 --- 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 @@ -12,26 +12,16 @@ 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 +class StreamThumbnailPlugin : FlutterPlugin, StreamThumbnailHostApi { private val TAG = "ThumbnailPlugin" private var context: Context? = null @@ -42,208 +32,62 @@ class StreamThumbnailPlugin : FlutterPlugin, MethodCallHandler { 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() - } + StreamThumbnailHostApi.setUp(flutterPluginBinding.binaryMessenger, this) } override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { context = null - channel.setMethodCallHandler(null) + StreamThumbnailHostApi.setUp(binding.binaryMessenger, 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) + override fun thumbnailData(request: ThumbnailRequest, callback: (Result) -> Unit) { + executeAsync(callback) { buildThumbnailData(request) } } - 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) + override fun thumbnailFile(request: ThumbnailRequest, callback: (Result) -> Unit) { + executeAsync(callback) { buildThumbnailFile(request) } } - 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() + /** + * Runs [block] on the background executor and delivers its outcome back to Flutter on the + * main thread via [callback], exactly once. + */ + private fun executeAsync(callback: (Result) -> Unit, block: () -> T) { + executor.execute { + val result = try { + Result.success(block()) + } catch (e: Exception) { + Result.failure(FlutterError("THUMBNAIL_ERROR", e.message, e.stackTraceToString())) + } + Handler(Looper.getMainLooper()).post { callback(result) } } - 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() + private fun buildThumbnailData(request: ThumbnailRequest): ByteArray { + val bitmap = createVideoThumbnail( + request.video, + request.headers ?: emptyMap(), + request.maxHeight.toInt(), + request.maxWidth.toInt(), + request.timeMs.toInt() + ) ?: throw IOException("Failed to generate a thumbnail for the video.") val stream = ByteArrayOutputStream() - bitmap.compress(intToFormat(format), quality, stream) + bitmap.compress(compressFormat(request.format), request.quality.toInt(), 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) + private fun buildThumbnailFile(request: ThumbnailRequest): String { + val bytes = buildThumbnailData(request) + val ext = formatExt(request.format) + val vidPath = request.video 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) { + var savePath = request.thumbnailPath + if (savePath == null && !isLocalFile) { savePath = context?.cacheDir?.absolutePath } @@ -268,19 +112,6 @@ class StreamThumbnailPlugin : FlutterPlugin, MethodCallHandler { 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. @@ -292,7 +123,7 @@ class StreamThumbnailPlugin : FlutterPlugin, MethodCallHandler { @Throws(IOException::class) fun createVideoThumbnail( video: String, - headers: HashMap, + headers: Map, targetH: Int, targetW: Int, timeMs: Int @@ -387,22 +218,19 @@ class StreamThumbnailPlugin : FlutterPlugin, MethodCallHandler { } } - private fun intToFormat(format: Int): Bitmap.CompressFormat { + private fun compressFormat(format: ThumbnailFormat): Bitmap.CompressFormat { return when (format) { - 0 -> Bitmap.CompressFormat.JPEG - 1 -> Bitmap.CompressFormat.PNG - 2 -> Bitmap.CompressFormat.WEBP - else -> Bitmap.CompressFormat.JPEG + ThumbnailFormat.JPEG -> Bitmap.CompressFormat.JPEG + ThumbnailFormat.PNG -> Bitmap.CompressFormat.PNG + ThumbnailFormat.WEBP -> Bitmap.CompressFormat.WEBP } } - private fun formatExt(format: Int): String { + private fun formatExt(format: ThumbnailFormat): String { return when (format) { - 0 -> "jpg" - 1 -> "png" - 2 -> "webp" - else -> "jpg" + ThumbnailFormat.JPEG -> "jpg" + ThumbnailFormat.PNG -> "png" + ThumbnailFormat.WEBP -> "webp" } } - -} \ No newline at end of file +} diff --git a/packages/stream_thumbnail/example/.metadata b/packages/stream_thumbnail/example/.metadata index 3ddd7104..9f0c2f4d 100644 --- a/packages/stream_thumbnail/example/.metadata +++ b/packages/stream_thumbnail/example/.metadata @@ -21,9 +21,18 @@ migration: - platform: ios create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 + - platform: linux + create_revision: 84fc5cbb223bc12f83d65b647ff8a56caf779ffd + base_revision: 84fc5cbb223bc12f83d65b647ff8a56caf779ffd + - platform: macos + create_revision: 84fc5cbb223bc12f83d65b647ff8a56caf779ffd + base_revision: 84fc5cbb223bc12f83d65b647ff8a56caf779ffd - platform: web create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 + - platform: windows + create_revision: 84fc5cbb223bc12f83d65b647ff8a56caf779ffd + base_revision: 84fc5cbb223bc12f83d65b647ff8a56caf779ffd # User provided section diff --git a/packages/stream_thumbnail/example/assets/sample_video.mp4 b/packages/stream_thumbnail/example/assets/sample_video.mp4 new file mode 100644 index 00000000..de2782b0 Binary files /dev/null and b/packages/stream_thumbnail/example/assets/sample_video.mp4 differ diff --git a/packages/stream_thumbnail/example/integration_test/plugin_smoke_test.dart b/packages/stream_thumbnail/example/integration_test/plugin_smoke_test.dart new file mode 100644 index 00000000..c48903a8 --- /dev/null +++ b/packages/stream_thumbnail/example/integration_test/plugin_smoke_test.dart @@ -0,0 +1,93 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:stream_thumbnail/stream_thumbnail.dart'; + +/// Remote-URL coverage needs network access, so it is opt-in: +/// `flutter test integration_test --dart-define=stream_thumbnail.network=true`. +// ignore: do_not_use_environment, a dart-define is the only way to toggle this on a device. +const _networkTests = bool.fromEnvironment('stream_thumbnail.network'); + +const _remoteVideo = 'https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4'; + +// WebP encoding isn't implemented by the Windows (Media Foundation + WIC) +// backend yet; it reports UNSUPPORTED_FORMAT instead. +final _webpSupported = !Platform.isWindows; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + // The plugin takes a filesystem path, so the bundled fixture has to be + // unpacked out of the asset bundle before it can be thumbnailed. + late final String video; + + setUpAll(() async { + final bytes = await rootBundle.load('assets/sample_video.mp4'); + final file = File('${Directory.systemTemp.path}/stream_thumbnail_sample_video.mp4'); + await file.writeAsBytes(bytes.buffer.asUint8List(bytes.offsetInBytes, bytes.lengthInBytes), flush: true); + video = file.path; + }); + + testWidgets('thumbnailData generates real jpeg bytes from a local video', (tester) async { + final bytes = await StreamThumbnail.thumbnailData( + video: video, + imageFormat: StreamThumbnailFormat.jpeg, + maxWidth: 300, + quality: 75, + ); + + expect(bytes.length, greaterThan(0)); + // JPEG magic bytes. + expect(bytes[0], 0xFF); + expect(bytes[1], 0xD8); + }); + + testWidgets('thumbnailData generates real png bytes from a local video', (tester) async { + final bytes = await StreamThumbnail.thumbnailData( + video: video, + maxWidth: 300, + ); + + expect(bytes.length, greaterThan(0)); + // PNG magic bytes. + expect(bytes.take(4), [0x89, 0x50, 0x4E, 0x47]); + }); + + testWidgets( + 'thumbnailData generates real webp bytes from a local video', + (tester) async { + final bytes = await StreamThumbnail.thumbnailData( + video: video, + imageFormat: StreamThumbnailFormat.webp, + maxWidth: 300, + quality: 80, + ); + + expect(bytes.length, greaterThan(0)); + // RIFF....WEBP header. + expect(bytes.take(4), 'RIFF'.codeUnits); + expect(bytes.skip(8).take(4), 'WEBP'.codeUnits); + }, + skip: !_webpSupported, + ); + + testWidgets( + 'thumbnailData generates real jpeg bytes from a remote video', + (tester) async { + final bytes = await StreamThumbnail.thumbnailData( + video: _remoteVideo, + imageFormat: StreamThumbnailFormat.jpeg, + maxWidth: 300, + quality: 75, + ); + + expect(bytes.length, greaterThan(0)); + // JPEG magic bytes. + expect(bytes[0], 0xFF); + expect(bytes[1], 0xD8); + }, + skip: !_networkTests, + ); +} diff --git a/packages/stream_thumbnail/example/ios/Runner.xcodeproj/project.pbxproj b/packages/stream_thumbnail/example/ios/Runner.xcodeproj/project.pbxproj index 490cc977..948c0549 100644 --- a/packages/stream_thumbnail/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/stream_thumbnail/example/ios/Runner.xcodeproj/project.pbxproj @@ -364,7 +364,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -491,7 +491,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -542,7 +542,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; diff --git a/packages/stream_thumbnail/example/linux/.gitignore b/packages/stream_thumbnail/example/linux/.gitignore new file mode 100644 index 00000000..d3896c98 --- /dev/null +++ b/packages/stream_thumbnail/example/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/packages/stream_thumbnail/example/linux/CMakeLists.txt b/packages/stream_thumbnail/example/linux/CMakeLists.txt new file mode 100644 index 00000000..c0f287ae --- /dev/null +++ b/packages/stream_thumbnail/example/linux/CMakeLists.txt @@ -0,0 +1,128 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "stream_thumbnail_example") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "io.getstream.stream_thumbnail_example") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/packages/stream_thumbnail/example/linux/flutter/CMakeLists.txt b/packages/stream_thumbnail/example/linux/flutter/CMakeLists.txt new file mode 100644 index 00000000..d5bd0164 --- /dev/null +++ b/packages/stream_thumbnail/example/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/packages/stream_thumbnail/example/linux/flutter/generated_plugin_registrant.cc b/packages/stream_thumbnail/example/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 00000000..04dc8cd6 --- /dev/null +++ b/packages/stream_thumbnail/example/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include + +void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) stream_thumbnail_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "StreamThumbnailPlugin"); + stream_thumbnail_plugin_register_with_registrar(stream_thumbnail_registrar); +} diff --git a/packages/stream_thumbnail/example/linux/flutter/generated_plugin_registrant.h b/packages/stream_thumbnail/example/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 00000000..e0f0a47b --- /dev/null +++ b/packages/stream_thumbnail/example/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/packages/stream_thumbnail/example/linux/flutter/generated_plugins.cmake b/packages/stream_thumbnail/example/linux/flutter/generated_plugins.cmake new file mode 100644 index 00000000..2d772a36 --- /dev/null +++ b/packages/stream_thumbnail/example/linux/flutter/generated_plugins.cmake @@ -0,0 +1,24 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + stream_thumbnail +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/packages/stream_thumbnail/example/linux/runner/CMakeLists.txt b/packages/stream_thumbnail/example/linux/runner/CMakeLists.txt new file mode 100644 index 00000000..e97dabc7 --- /dev/null +++ b/packages/stream_thumbnail/example/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/packages/stream_thumbnail/example/linux/runner/main.cc b/packages/stream_thumbnail/example/linux/runner/main.cc new file mode 100644 index 00000000..e7c5c543 --- /dev/null +++ b/packages/stream_thumbnail/example/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/packages/stream_thumbnail/example/linux/runner/my_application.cc b/packages/stream_thumbnail/example/linux/runner/my_application.cc new file mode 100644 index 00000000..f4260ea0 --- /dev/null +++ b/packages/stream_thumbnail/example/linux/runner/my_application.cc @@ -0,0 +1,148 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Called when first Flutter frame received. +static void first_frame_cb(MyApplication* self, FlView* view) { + gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); +} + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "stream_thumbnail_example"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "stream_thumbnail_example"); + } + + gtk_window_set_default_size(window, 1280, 720); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments( + project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + GdkRGBA background_color; + // Background defaults to black, override it here if necessary, e.g. #00000000 + // for transparent. + gdk_rgba_parse(&background_color, "#000000"); + fl_view_set_background_color(view, &background_color); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + // Show the window when Flutter renders. + // Requires the view to be realized so we can start rendering. + g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), + self); + gtk_widget_realize(GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, + gchar*** arguments, + int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = + my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, "flags", + G_APPLICATION_NON_UNIQUE, nullptr)); +} diff --git a/packages/stream_thumbnail/example/linux/runner/my_application.h b/packages/stream_thumbnail/example/linux/runner/my_application.h new file mode 100644 index 00000000..db16367a --- /dev/null +++ b/packages/stream_thumbnail/example/linux/runner/my_application.h @@ -0,0 +1,21 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, + my_application, + MY, + APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/packages/stream_thumbnail/example/macos/.gitignore b/packages/stream_thumbnail/example/macos/.gitignore new file mode 100644 index 00000000..746adbb6 --- /dev/null +++ b/packages/stream_thumbnail/example/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/packages/stream_thumbnail/example/macos/Flutter/Flutter-Debug.xcconfig b/packages/stream_thumbnail/example/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 00000000..c2efd0b6 --- /dev/null +++ b/packages/stream_thumbnail/example/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/packages/stream_thumbnail/example/macos/Flutter/Flutter-Release.xcconfig b/packages/stream_thumbnail/example/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 00000000..c2efd0b6 --- /dev/null +++ b/packages/stream_thumbnail/example/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/packages/stream_thumbnail/example/macos/Runner.xcodeproj/project.pbxproj b/packages/stream_thumbnail/example/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 00000000..950a4b19 --- /dev/null +++ b/packages/stream_thumbnail/example/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,733 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* stream_thumbnail_example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "stream_thumbnail_example.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + 784666492D4C4C64000A1A5F /* FlutterFramework */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterFramework; path = ephemeral/Packages/.packages/FlutterFramework; sourceTree = ""; }; + 78DABEA22ED26510000E7860 /* stream_thumbnail */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = stream_thumbnail; path = ../../../macos/stream_thumbnail; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* stream_thumbnail_example.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 78DABEA22ED26510000E7860 /* stream_thumbnail */, + 784666492D4C4C64000A1A5F /* FlutterFramework */, + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* stream_thumbnail_example.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + 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)/stream_thumbnail_example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/stream_thumbnail_example"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + 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)/stream_thumbnail_example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/stream_thumbnail_example"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + 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)/stream_thumbnail_example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/stream_thumbnail_example"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + 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_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + 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_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + 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_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + 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_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + 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_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + 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_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + 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_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* 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 = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/packages/stream_thumbnail/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/stream_thumbnail/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/packages/stream_thumbnail/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/stream_thumbnail/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/packages/stream_thumbnail/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..ff75c864 --- /dev/null +++ b/packages/stream_thumbnail/example/macos/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/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/stream_thumbnail/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 00000000..0cdf4e2a --- /dev/null +++ b/packages/stream_thumbnail/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/stream_thumbnail/example/macos/Runner.xcworkspace/contents.xcworkspacedata b/packages/stream_thumbnail/example/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..1d526a16 --- /dev/null +++ b/packages/stream_thumbnail/example/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/packages/stream_thumbnail/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/stream_thumbnail/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/packages/stream_thumbnail/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/stream_thumbnail/example/macos/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/packages/stream_thumbnail/example/macos/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..ff75c864 --- /dev/null +++ b/packages/stream_thumbnail/example/macos/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/macos/Runner/AppDelegate.swift b/packages/stream_thumbnail/example/macos/Runner/AppDelegate.swift new file mode 100644 index 00000000..b3c17614 --- /dev/null +++ b/packages/stream_thumbnail/example/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/packages/stream_thumbnail/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/packages/stream_thumbnail/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..a2ec33f1 --- /dev/null +++ b/packages/stream_thumbnail/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/packages/stream_thumbnail/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/packages/stream_thumbnail/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 00000000..82b6f9d9 Binary files /dev/null and b/packages/stream_thumbnail/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/packages/stream_thumbnail/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/packages/stream_thumbnail/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 00000000..13b35eba Binary files /dev/null and b/packages/stream_thumbnail/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/packages/stream_thumbnail/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/packages/stream_thumbnail/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 00000000..0a3f5fa4 Binary files /dev/null and b/packages/stream_thumbnail/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/packages/stream_thumbnail/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/packages/stream_thumbnail/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 00000000..bdb57226 Binary files /dev/null and b/packages/stream_thumbnail/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/packages/stream_thumbnail/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/packages/stream_thumbnail/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 00000000..f083318e Binary files /dev/null and b/packages/stream_thumbnail/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/packages/stream_thumbnail/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/packages/stream_thumbnail/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 00000000..326c0e72 Binary files /dev/null and b/packages/stream_thumbnail/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/packages/stream_thumbnail/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/packages/stream_thumbnail/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 00000000..2f1632cf Binary files /dev/null and b/packages/stream_thumbnail/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/packages/stream_thumbnail/example/macos/Runner/Base.lproj/MainMenu.xib b/packages/stream_thumbnail/example/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 00000000..80e867a4 --- /dev/null +++ b/packages/stream_thumbnail/example/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/stream_thumbnail/example/macos/Runner/Configs/AppInfo.xcconfig b/packages/stream_thumbnail/example/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 00000000..0769ee91 --- /dev/null +++ b/packages/stream_thumbnail/example/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = stream_thumbnail_example + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = io.getstream.streamThumbnailExample + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2026 io.getstream. All rights reserved. diff --git a/packages/stream_thumbnail/example/macos/Runner/Configs/Debug.xcconfig b/packages/stream_thumbnail/example/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 00000000..36b0fd94 --- /dev/null +++ b/packages/stream_thumbnail/example/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/packages/stream_thumbnail/example/macos/Runner/Configs/Release.xcconfig b/packages/stream_thumbnail/example/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 00000000..dff4f495 --- /dev/null +++ b/packages/stream_thumbnail/example/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/packages/stream_thumbnail/example/macos/Runner/Configs/Warnings.xcconfig b/packages/stream_thumbnail/example/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 00000000..42bcbf47 --- /dev/null +++ b/packages/stream_thumbnail/example/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/packages/stream_thumbnail/example/macos/Runner/DebugProfile.entitlements b/packages/stream_thumbnail/example/macos/Runner/DebugProfile.entitlements new file mode 100644 index 00000000..3ba6c126 --- /dev/null +++ b/packages/stream_thumbnail/example/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.client + + com.apple.security.network.server + + + diff --git a/packages/stream_thumbnail/example/macos/Runner/Info.plist b/packages/stream_thumbnail/example/macos/Runner/Info.plist new file mode 100644 index 00000000..4789daa6 --- /dev/null +++ b/packages/stream_thumbnail/example/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/packages/stream_thumbnail/example/macos/Runner/MainFlutterWindow.swift b/packages/stream_thumbnail/example/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 00000000..3cc05eb2 --- /dev/null +++ b/packages/stream_thumbnail/example/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/packages/stream_thumbnail/example/macos/Runner/Release.entitlements b/packages/stream_thumbnail/example/macos/Runner/Release.entitlements new file mode 100644 index 00000000..ee95ab7e --- /dev/null +++ b/packages/stream_thumbnail/example/macos/Runner/Release.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + + diff --git a/packages/stream_thumbnail/example/macos/RunnerTests/RunnerTests.swift b/packages/stream_thumbnail/example/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 00000000..61f3bd1f --- /dev/null +++ b/packages/stream_thumbnail/example/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +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/pubspec.yaml b/packages/stream_thumbnail/example/pubspec.yaml index e0e6f9cf..94a17a27 100644 --- a/packages/stream_thumbnail/example/pubspec.yaml +++ b/packages/stream_thumbnail/example/pubspec.yaml @@ -15,6 +15,15 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter + integration_test: + sdk: flutter flutter: uses-material-design: true + assets: + # A tiny H.264 clip used by the integration tests, so they don't need + # network access. Generated with: + # ffmpeg -f lavfi -i testsrc=size=320x240:rate=15:duration=2 \ + # -c:v libx264 -profile:v baseline -level 3.0 -pix_fmt yuv420p \ + # -crf 30 -movflags +faststart assets/sample_video.mp4 + - assets/sample_video.mp4 diff --git a/packages/stream_thumbnail/example/windows/.gitignore b/packages/stream_thumbnail/example/windows/.gitignore new file mode 100644 index 00000000..d492d0d9 --- /dev/null +++ b/packages/stream_thumbnail/example/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/packages/stream_thumbnail/example/windows/CMakeLists.txt b/packages/stream_thumbnail/example/windows/CMakeLists.txt new file mode 100644 index 00000000..857b512e --- /dev/null +++ b/packages/stream_thumbnail/example/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(stream_thumbnail_example LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "stream_thumbnail_example") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/packages/stream_thumbnail/example/windows/flutter/CMakeLists.txt b/packages/stream_thumbnail/example/windows/flutter/CMakeLists.txt new file mode 100644 index 00000000..903f4899 --- /dev/null +++ b/packages/stream_thumbnail/example/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/packages/stream_thumbnail/example/windows/flutter/generated_plugins.cmake b/packages/stream_thumbnail/example/windows/flutter/generated_plugins.cmake new file mode 100644 index 00000000..2927207b --- /dev/null +++ b/packages/stream_thumbnail/example/windows/flutter/generated_plugins.cmake @@ -0,0 +1,24 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + stream_thumbnail +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/packages/stream_thumbnail/example/windows/runner/CMakeLists.txt b/packages/stream_thumbnail/example/windows/runner/CMakeLists.txt new file mode 100644 index 00000000..394917c0 --- /dev/null +++ b/packages/stream_thumbnail/example/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/packages/stream_thumbnail/example/windows/runner/Runner.rc b/packages/stream_thumbnail/example/windows/runner/Runner.rc new file mode 100644 index 00000000..e321247a --- /dev/null +++ b/packages/stream_thumbnail/example/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "io.getstream" "\0" + VALUE "FileDescription", "stream_thumbnail_example" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "stream_thumbnail_example" "\0" + VALUE "LegalCopyright", "Copyright (C) 2026 io.getstream. All rights reserved." "\0" + VALUE "OriginalFilename", "stream_thumbnail_example.exe" "\0" + VALUE "ProductName", "stream_thumbnail_example" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/packages/stream_thumbnail/example/windows/runner/flutter_window.cpp b/packages/stream_thumbnail/example/windows/runner/flutter_window.cpp new file mode 100644 index 00000000..955ee303 --- /dev/null +++ b/packages/stream_thumbnail/example/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/packages/stream_thumbnail/example/windows/runner/flutter_window.h b/packages/stream_thumbnail/example/windows/runner/flutter_window.h new file mode 100644 index 00000000..6da0652f --- /dev/null +++ b/packages/stream_thumbnail/example/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/packages/stream_thumbnail/example/windows/runner/main.cpp b/packages/stream_thumbnail/example/windows/runner/main.cpp new file mode 100644 index 00000000..5827bb06 --- /dev/null +++ b/packages/stream_thumbnail/example/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"stream_thumbnail_example", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/packages/stream_thumbnail/example/windows/runner/resource.h b/packages/stream_thumbnail/example/windows/runner/resource.h new file mode 100644 index 00000000..66a65d1e --- /dev/null +++ b/packages/stream_thumbnail/example/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/packages/stream_thumbnail/example/windows/runner/resources/app_icon.ico b/packages/stream_thumbnail/example/windows/runner/resources/app_icon.ico new file mode 100644 index 00000000..c04e20ca Binary files /dev/null and b/packages/stream_thumbnail/example/windows/runner/resources/app_icon.ico differ diff --git a/packages/stream_thumbnail/example/windows/runner/runner.exe.manifest b/packages/stream_thumbnail/example/windows/runner/runner.exe.manifest new file mode 100644 index 00000000..153653e8 --- /dev/null +++ b/packages/stream_thumbnail/example/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/packages/stream_thumbnail/example/windows/runner/utils.cpp b/packages/stream_thumbnail/example/windows/runner/utils.cpp new file mode 100644 index 00000000..3cb71466 --- /dev/null +++ b/packages/stream_thumbnail/example/windows/runner/utils.cpp @@ -0,0 +1,69 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + // First, find the length of the string with a safe upper bound (CWE-126). + // UNICODE_STRING_MAX_CHARS (32767) is the maximum length of a UNICODE_STRING. + int input_length = static_cast(wcsnlen(utf16_string, UNICODE_STRING_MAX_CHARS)); + // Now use that bounded length to determine the required buffer size. + // When an explicit length is passed, WideCharToMultiByte does not include + // the null terminator in its returned size. + int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, nullptr, 0, nullptr, nullptr); + std::string utf8_string; + if (target_length == 0 || static_cast(target_length) > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/packages/stream_thumbnail/example/windows/runner/utils.h b/packages/stream_thumbnail/example/windows/runner/utils.h new file mode 100644 index 00000000..3879d547 --- /dev/null +++ b/packages/stream_thumbnail/example/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/packages/stream_thumbnail/example/windows/runner/win32_window.cpp b/packages/stream_thumbnail/example/windows/runner/win32_window.cpp new file mode 100644 index 00000000..60608d0f --- /dev/null +++ b/packages/stream_thumbnail/example/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/packages/stream_thumbnail/example/windows/runner/win32_window.h b/packages/stream_thumbnail/example/windows/runner/win32_window.h new file mode 100644 index 00000000..e901dde6 --- /dev/null +++ b/packages/stream_thumbnail/example/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_ diff --git a/packages/stream_thumbnail/ios/stream_thumbnail.podspec b/packages/stream_thumbnail/ios/stream_thumbnail.podspec index 45027547..dbe699b1 100644 --- a/packages/stream_thumbnail/ios/stream_thumbnail.podspec +++ b/packages/stream_thumbnail/ios/stream_thumbnail.podspec @@ -12,13 +12,10 @@ A Flutter plugin for creating a thumbnail from a local video file or from a vide 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.source_files = 'stream_thumbnail/Sources/stream_thumbnail/**/*.swift' s.dependency 'Flutter' s.dependency 'libwebp' s.ios.deployment_target = '13.0' + s.swift_version = '5.9' end diff --git a/packages/stream_thumbnail/ios/stream_thumbnail/Package.swift b/packages/stream_thumbnail/ios/stream_thumbnail/Package.swift index a8705823..5ef20e2d 100644 --- a/packages/stream_thumbnail/ios/stream_thumbnail/Package.swift +++ b/packages/stream_thumbnail/ios/stream_thumbnail/Package.swift @@ -21,9 +21,6 @@ let package = Package( 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/Messages.g.swift b/packages/stream_thumbnail/ios/stream_thumbnail/Sources/stream_thumbnail/Messages.g.swift new file mode 100644 index 00000000..8d6123b8 --- /dev/null +++ b/packages/stream_thumbnail/ios/stream_thumbnail/Sources/stream_thumbnail/Messages.g.swift @@ -0,0 +1,365 @@ +// Autogenerated from Pigeon (v27.3.0), do not edit directly. +// See also: https://pub.dev/packages/pigeon + +import Foundation + +#if os(iOS) + import Flutter +#elseif os(macOS) + import FlutterMacOS +#else + #error("Unsupported platform.") +#endif + +/// Error class for passing custom error details to Dart side. +final class PigeonError: Error { + let code: String + let message: String? + let details: Sendable? + + init(code: String, message: String?, details: Sendable?) { + self.code = code + self.message = message + self.details = details + } + + var localizedDescription: String { + return + "PigeonError(code: \(code), message: \(message ?? ""), details: \(details ?? "")" + } +} + +private func wrapResult(_ result: Any?) -> [Any?] { + return [result] +} + +private func wrapError(_ error: Any) -> [Any?] { + if let pigeonError = error as? PigeonError { + return [ + pigeonError.code, + pigeonError.message, + pigeonError.details, + ] + } + if let flutterError = error as? FlutterError { + return [ + flutterError.code, + flutterError.message, + flutterError.details, + ] + } + return [ + "\(error)", + "\(Swift.type(of: error))", + "Stacktrace: \(Thread.callStackSymbols)", + ] +} + +enum MessagesPigeonInternal { + static func isNullish(_ value: Any?) -> Bool { + guard let innerValue = value else { + return true + } + + if case Optional.some(Optional.none) = value { + return true + } + + return innerValue is NSNull + } + static func doubleEquals(_ lhs: Double, _ rhs: Double) -> Bool { + return (lhs.isNaN && rhs.isNaN) || lhs == rhs + } + + static func doubleHash(_ value: Double, _ hasher: inout Hasher) { + if value.isNaN { + hasher.combine(0x7FF8000000000000) + } else { + // Normalize -0.0 to 0.0 + hasher.combine(value == 0 ? 0 : value) + } + } + + static func deepEquals(_ lhs: Any?, _ rhs: Any?) -> Bool { + let cleanLhs = nilOrValue(lhs) as Any? + let cleanRhs = nilOrValue(rhs) as Any? + switch (cleanLhs, cleanRhs) { + case (nil, nil): + return true + + case (nil, _), (_, nil): + return false + + case (let lhs as AnyObject, let rhs as AnyObject) where lhs === rhs: + return true + + case is (Void, Void): + return true + + case (let lhsArray, let rhsArray) as ([Any?], [Any?]): + guard lhsArray.count == rhsArray.count else { return false } + for (index, element) in lhsArray.enumerated() { + if !deepEquals(element, rhsArray[index]) { + return false + } + } + return true + + case (let lhsArray, let rhsArray) as ([Double], [Double]): + guard lhsArray.count == rhsArray.count else { return false } + for (index, element) in lhsArray.enumerated() { + if !doubleEquals(element, rhsArray[index]) { + return false + } + } + return true + + case (let lhsDictionary, let rhsDictionary) as ([AnyHashable: Any?], [AnyHashable: Any?]): + guard lhsDictionary.count == rhsDictionary.count else { return false } + for (lhsKey, lhsValue) in lhsDictionary { + var found = false + for (rhsKey, rhsValue) in rhsDictionary { + if deepEquals(lhsKey, rhsKey) { + if deepEquals(lhsValue, rhsValue) { + found = true + break + } else { + return false + } + } + } + if !found { return false } + } + return true + + case (let lhs as Double, let rhs as Double): + return doubleEquals(lhs, rhs) + + case (let lhsHashable, let rhsHashable) as (AnyHashable, AnyHashable): + return lhsHashable == rhsHashable + + default: + return false + } + } + + static func deepHash(value: Any?, hasher: inout Hasher) { + let cleanValue = nilOrValue(value) as Any? + if let cleanValue = cleanValue { + if let doubleValue = cleanValue as? Double { + doubleHash(doubleValue, &hasher) + } else if let valueList = cleanValue as? [Any?] { + for item in valueList { + deepHash(value: item, hasher: &hasher) + } + } else if let valueList = cleanValue as? [Double] { + for item in valueList { + doubleHash(item, &hasher) + } + } else if let valueDict = cleanValue as? [AnyHashable: Any?] { + var result = 0 + for (key, value) in valueDict { + var entryKeyHasher = Hasher() + deepHash(value: key, hasher: &entryKeyHasher) + var entryValueHasher = Hasher() + deepHash(value: value, hasher: &entryValueHasher) + result = result &+ ((entryKeyHasher.finalize() &* 31) ^ entryValueHasher.finalize()) + } + hasher.combine(result) + } else if let hashableValue = cleanValue as? AnyHashable { + hasher.combine(hashableValue) + } else { + hasher.combine(String(describing: cleanValue)) + } + } else { + hasher.combine(0) + } + } + +} + +private func nilOrValue(_ value: Any?) -> T? { + if value is NSNull { return nil } + return value as! T? +} + + +/// Wire representation of the image format for a generated thumbnail. +enum ThumbnailFormat: Int, CaseIterable { + case jpeg = 0 + case png = 1 + case webp = 2 +} + +/// A single thumbnail generation request sent to the native platform. +/// +/// Generated class from Pigeon that represents data sent in messages. +struct ThumbnailRequest: Hashable, CustomStringConvertible { + var video: String + var headers: [String: String]? = nil + var thumbnailPath: String? = nil + var format: ThumbnailFormat + var maxHeight: Int64 + var maxWidth: Int64 + var timeMs: Int64 + var quality: Int64 + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> ThumbnailRequest? { + let video = pigeonVar_list[0] as! String + let headers: [String: String]? = nilOrValue(pigeonVar_list[1]) + let thumbnailPath: String? = nilOrValue(pigeonVar_list[2]) + let format = pigeonVar_list[3] as! ThumbnailFormat + let maxHeight = pigeonVar_list[4] as! Int64 + let maxWidth = pigeonVar_list[5] as! Int64 + let timeMs = pigeonVar_list[6] as! Int64 + let quality = pigeonVar_list[7] as! Int64 + + return ThumbnailRequest( + video: video, + headers: headers, + thumbnailPath: thumbnailPath, + format: format, + maxHeight: maxHeight, + maxWidth: maxWidth, + timeMs: timeMs, + quality: quality + ) + } + func toList() -> [Any?] { + return [ + video, + headers, + thumbnailPath, + format, + maxHeight, + maxWidth, + timeMs, + quality, + ] + } + static func == (lhs: ThumbnailRequest, rhs: ThumbnailRequest) -> Bool { + if Swift.type(of: lhs) != Swift.type(of: rhs) { + return false + } + return MessagesPigeonInternal.deepEquals(lhs.video, rhs.video) && MessagesPigeonInternal.deepEquals(lhs.headers, rhs.headers) && MessagesPigeonInternal.deepEquals(lhs.thumbnailPath, rhs.thumbnailPath) && MessagesPigeonInternal.deepEquals(lhs.format, rhs.format) && MessagesPigeonInternal.deepEquals(lhs.maxHeight, rhs.maxHeight) && MessagesPigeonInternal.deepEquals(lhs.maxWidth, rhs.maxWidth) && MessagesPigeonInternal.deepEquals(lhs.timeMs, rhs.timeMs) && MessagesPigeonInternal.deepEquals(lhs.quality, rhs.quality) + } + + func hash(into hasher: inout Hasher) { + hasher.combine("ThumbnailRequest") + MessagesPigeonInternal.deepHash(value: video, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: headers, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: thumbnailPath, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: format, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: maxHeight, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: maxWidth, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: timeMs, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: quality, hasher: &hasher) + } + + public var description: String { + return "ThumbnailRequest(video: \(String(describing: video)), headers: \(String(describing: headers)), thumbnailPath: \(String(describing: thumbnailPath)), format: \(String(describing: format)), maxHeight: \(String(describing: maxHeight)), maxWidth: \(String(describing: maxWidth)), timeMs: \(String(describing: timeMs)), quality: \(String(describing: quality)))" + } +} + +private class MessagesPigeonCodecReader: FlutterStandardReader { + override func readValue(ofType type: UInt8) -> Any? { + switch type { + case 129: + let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?) + if let enumResultAsInt = enumResultAsInt { + return ThumbnailFormat(rawValue: enumResultAsInt) + } + return nil + case 130: + return ThumbnailRequest.fromList(self.readValue() as! [Any?]) + default: + return super.readValue(ofType: type) + } + } +} + +private class MessagesPigeonCodecWriter: FlutterStandardWriter { + override func writeValue(_ value: Any) { + if let value = value as? ThumbnailFormat { + super.writeByte(129) + super.writeValue(value.rawValue) + } else if let value = value as? ThumbnailRequest { + super.writeByte(130) + super.writeValue(value.toList()) + } else { + super.writeValue(value) + } + } +} + +private class MessagesPigeonCodecReaderWriter: FlutterStandardReaderWriter { + override func reader(with data: Data) -> FlutterStandardReader { + return MessagesPigeonCodecReader(data: data) + } + + override func writer(with data: NSMutableData) -> FlutterStandardWriter { + return MessagesPigeonCodecWriter(data: data) + } +} + +class MessagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable { + static let shared = MessagesPigeonCodec(readerWriter: MessagesPigeonCodecReaderWriter()) +} + + +/// Generated protocol from Pigeon that represents a handler of messages from Flutter. +protocol StreamThumbnailHostApi { + /// Generates a thumbnail for [ThumbnailRequest.video] and returns its bytes. + func thumbnailData(request: ThumbnailRequest, completion: @escaping (Result) -> Void) + /// Generates a thumbnail for [ThumbnailRequest.video] and returns the path + /// it was written to. + func thumbnailFile(request: ThumbnailRequest, completion: @escaping (Result) -> Void) +} + +/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. +class StreamThumbnailHostApiSetup { + static var codec: FlutterStandardMessageCodec { MessagesPigeonCodec.shared } + /// Sets up an instance of `StreamThumbnailHostApi` to handle messages through the `binaryMessenger`. + static func setUp(binaryMessenger: FlutterBinaryMessenger, api: StreamThumbnailHostApi?, messageChannelSuffix: String = "") { + let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" + /// Generates a thumbnail for [ThumbnailRequest.video] and returns its bytes. + let thumbnailDataChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.stream_thumbnail.StreamThumbnailHostApi.thumbnailData\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + thumbnailDataChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let requestArg = args[0] as! ThumbnailRequest + api.thumbnailData(request: requestArg) { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + thumbnailDataChannel.setMessageHandler(nil) + } + /// Generates a thumbnail for [ThumbnailRequest.video] and returns the path + /// it was written to. + let thumbnailFileChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.stream_thumbnail.StreamThumbnailHostApi.thumbnailFile\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + thumbnailFileChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let requestArg = args[0] as! ThumbnailRequest + api.thumbnailFile(request: requestArg) { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + thumbnailFileChannel.setMessageHandler(nil) + } + } +} 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 deleted file mode 100644 index 09caa669..00000000 --- a/packages/stream_thumbnail/ios/stream_thumbnail/Sources/stream_thumbnail/StreamThumbnailPlugin.m +++ /dev/null @@ -1,190 +0,0 @@ -#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/StreamThumbnailPlugin.swift b/packages/stream_thumbnail/ios/stream_thumbnail/Sources/stream_thumbnail/StreamThumbnailPlugin.swift new file mode 100644 index 00000000..f9ae15b8 --- /dev/null +++ b/packages/stream_thumbnail/ios/stream_thumbnail/Sources/stream_thumbnail/StreamThumbnailPlugin.swift @@ -0,0 +1,211 @@ +import AVFoundation +import Flutter +import UIKit +import libwebp + +/// A generation failure that couldn't produce a frame/encode, or a failure +/// writing the encoded thumbnail to disk. +private enum ThumbnailError: Error { + case generationFailed + case writeFailed(NSError) +} + +public class StreamThumbnailPlugin: NSObject, FlutterPlugin, StreamThumbnailHostApi { + public static func register(with registrar: FlutterPluginRegistrar) { + let instance = StreamThumbnailPlugin() + StreamThumbnailHostApiSetup.setUp(binaryMessenger: registrar.messenger(), api: instance) + } + + func thumbnailData( + request: ThumbnailRequest, completion: @escaping (Result) -> Void + ) { + DispatchQueue.global(qos: .userInitiated).async { + do { + let data = try Self.generateThumbnailData(request: request) + let result = FlutterStandardTypedData(bytes: data) + DispatchQueue.main.async { completion(.success(result)) } + } catch { + DispatchQueue.main.async { completion(.failure(Self.pigeonError(for: error))) } + } + } + } + + func thumbnailFile( + request: ThumbnailRequest, completion: @escaping (Result) -> Void + ) { + DispatchQueue.global(qos: .userInitiated).async { + do { + let path = try Self.writeThumbnailFile(request: request) + DispatchQueue.main.async { completion(.success(path)) } + } catch { + DispatchQueue.main.async { completion(.failure(Self.pigeonError(for: error))) } + } + } + } + + private static func pigeonError(for error: Error) -> PigeonError { + switch error { + case ThumbnailError.generationFailed: + return PigeonError( + code: "THUMBNAIL_ERROR", message: "Failed to generate a thumbnail for the video.", details: nil) + case ThumbnailError.writeFailed(let nsError): + return PigeonError(code: "Error \(nsError.code)", message: nsError.domain, details: nsError.localizedDescription) + default: + return PigeonError(code: "THUMBNAIL_ERROR", message: (error as NSError).localizedDescription, details: nil) + } + } + + private static func videoURL(for video: String) throws -> URL { + if video.hasPrefix("file://") { + return URL(fileURLWithPath: String(video.dropFirst(7))) + } else if video.hasPrefix("/") { + return URL(fileURLWithPath: video) + } else if let url = URL(string: video) { + return url + } + throw ThumbnailError.generationFailed + } + + private static func writeThumbnailFile(request: ThumbnailRequest) throws -> String { + let data = try generateThumbnailData(request: request) + let videoURL = try self.videoURL(for: request.video) + let isLocalFile = request.video.hasPrefix("/") || request.video.hasPrefix("file://") + let ext = fileExtension(for: request.format) + + var savePath = request.thumbnailPath + if savePath == nil && !isLocalFile { + savePath = NSSearchPathForDirectoriesInDomains(.cachesDirectory, .userDomainMask, true).last + } + + var thumbnailURL = videoURL.deletingPathExtension().appendingPathExtension(ext) + if let savePath, !savePath.isEmpty { + let lastPart = thumbnailURL.lastPathComponent + thumbnailURL = URL(fileURLWithPath: savePath) + if thumbnailURL.pathExtension != ext { + thumbnailURL = thumbnailURL.appendingPathComponent(lastPart) + } + } + + do { + try data.write(to: thumbnailURL, options: .atomic) + } catch { + throw ThumbnailError.writeFailed(error as NSError) + } + + return thumbnailURL.path + } + + private static func generateThumbnailData(request: ThumbnailRequest) throws -> Data { + let url = try videoURL(for: request.video) + + var options: [String: Any]? + if let headers = request.headers, !headers.isEmpty { + options = ["AVURLAssetHTTPHeaderFieldsKey": headers] + } + + let asset = AVURLAsset(url: url, options: options) + let generator = AVAssetImageGenerator(asset: asset) + generator.appliesPreferredTrackTransform = true + generator.maximumSize = CGSize(width: CGFloat(request.maxWidth), height: CGFloat(request.maxHeight)) + generator.requestedTimeToleranceBefore = .zero + generator.requestedTimeToleranceAfter = CMTime(value: 100, timescale: 1000) + + guard + let cgImage = try? generator.copyCGImage( + at: CMTime(value: request.timeMs, timescale: 1000), actualTime: nil) + else { + throw ThumbnailError.generationFailed + } + + switch request.format { + case .jpeg: + guard let data = UIImage(cgImage: cgImage).jpegData(compressionQuality: CGFloat(request.quality) * 0.01) + else { + throw ThumbnailError.generationFailed + } + return data + case .png: + guard let data = UIImage(cgImage: cgImage).pngData() else { + throw ThumbnailError.generationFailed + } + return data + case .webp: + return try encodeWebP(cgImage: cgImage, quality: Int(request.quality)) + } + } + + private static func encodeWebP(cgImage: CGImage, quality: Int) throws -> Data { + guard cgImage.colorSpace?.model == .rgb else { + throw ThumbnailError.generationFailed + } + + let alphaInfo = cgImage.alphaInfo + guard alphaInfo == .premultipliedFirst || alphaInfo == .noneSkipFirst else { + throw ThumbnailError.generationFailed + } + guard let cfData = cgImage.dataProvider?.data else { + throw ThumbnailError.generationFailed + } + + let width = Int32(cgImage.width) + let height = Int32(cgImage.height) + let stride = Int32(cgImage.bytesPerRow) + let byteOrder = cgImage.bitmapInfo.intersection(.byteOrderMask) + + var bytes = [UInt8](repeating: 0, count: CFDataGetLength(cfData)) + bytes.withUnsafeMutableBufferPointer { buffer in + CFDataGetBytes(cfData, CFRange(location: 0, length: buffer.count), buffer.baseAddress) + } + + var output: UnsafeMutablePointer? + var size = 0 + + switch byteOrder { + case .byteOrder32Little: + // Little-endian (iPhone). + bytes.withUnsafeMutableBufferPointer { buffer in + if quality == 100 { + size = WebPEncodeLosslessBGRA(buffer.baseAddress, width, height, stride, &output) + } else { + size = WebPEncodeBGRA(buffer.baseAddress, width, height, stride, Float(quality), &output) + } + } + case .byteOrder32Big: + // Big-endian (iPhone Simulator). + bytes.withUnsafeMutableBufferPointer { buffer in + let base = buffer.baseAddress! + for y in 0..> 8) & 0x00FF_FFFF) + } + } + } + if quality == 100 { + size = WebPEncodeLosslessRGBA(base, width, height, stride, &output) + } else { + size = WebPEncodeRGBA(base, width, height, stride, Float(quality), &output) + } + } + default: + throw ThumbnailError.generationFailed + } + + guard size > 0, let output else { + throw ThumbnailError.generationFailed + } + + let data = Data(bytes: output, count: size) + WebPFree(output) + return data + } + + private static func fileExtension(for format: ThumbnailFormat) -> String { + switch format { + case .jpeg: return "jpg" + case .png: return "png" + case .webp: return "webp" + } + } +} 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 deleted file mode 100644 index 4ffd0132..00000000 --- a/packages/stream_thumbnail/ios/stream_thumbnail/Sources/stream_thumbnail/include/stream_thumbnail/StreamThumbnailPlugin.h +++ /dev/null @@ -1,4 +0,0 @@ -#import - -@interface StreamThumbnailPlugin : NSObject -@end diff --git a/packages/stream_thumbnail/lib/src/messages.g.dart b/packages/stream_thumbnail/lib/src/messages.g.dart new file mode 100644 index 00000000..4c22fb74 --- /dev/null +++ b/packages/stream_thumbnail/lib/src/messages.g.dart @@ -0,0 +1,278 @@ +// Autogenerated from Pigeon (v27.3.0), do not edit directly. +// See also: https://pub.dev/packages/pigeon +// ignore_for_file: unused_import, unused_shown_name +// ignore_for_file: type=lint + +import 'dart:async'; +import 'dart:typed_data' show Float64List, Int32List, Int64List; + +import 'package:flutter/services.dart'; +import 'package:meta/meta.dart' show immutable, protected, visibleForTesting; + +Object? _extractReplyValueOrThrow( + List? replyList, + String channelName, { + required bool isNullValid, +}) { + if (replyList == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel: "$channelName".', + ); + } else if (replyList.length > 1) { + throw PlatformException( + code: replyList[0]! as String, + message: replyList[1] as String?, + details: replyList[2], + ); + } else if (!isNullValid && (replyList.isNotEmpty && replyList[0] == null)) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } + return replyList.firstOrNull; +} + +bool _deepEquals(Object? a, Object? b) { + if (identical(a, b)) { + return true; + } + if (a is double && b is double) { + if (a.isNaN && b.isNaN) { + return true; + } + return a == b; + } + if (a is List && b is List) { + return a.length == b.length && a.indexed.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1])); + } + if (a is Map && b is Map) { + if (a.length != b.length) { + return false; + } + for (final MapEntry entryA in a.entries) { + bool found = false; + for (final MapEntry entryB in b.entries) { + if (_deepEquals(entryA.key, entryB.key)) { + if (_deepEquals(entryA.value, entryB.value)) { + found = true; + break; + } else { + return false; + } + } + } + if (!found) { + return false; + } + } + return true; + } + return a == b; +} + +int _deepHash(Object? value) { + if (value is List) { + return Object.hashAll(value.map(_deepHash)); + } + if (value is Map) { + int result = 0; + for (final MapEntry entry in value.entries) { + result += (_deepHash(entry.key) * 31) ^ _deepHash(entry.value); + } + return result; + } + if (value is double && value.isNaN) { + // Normalize NaN to a consistent hash. + return 0x7FF8000000000000.hashCode; + } + if (value is double && value == 0.0) { + // Normalize -0.0 to 0.0 so they have the same hash code. + return 0.0.hashCode; + } + return value.hashCode; +} + +/// Wire representation of the image format for a generated thumbnail. +enum ThumbnailFormat { + jpeg, + png, + webp, +} + +/// A single thumbnail generation request sent to the native platform. +class ThumbnailRequest { + ThumbnailRequest({ + required this.video, + this.headers, + this.thumbnailPath, + required this.format, + required this.maxHeight, + required this.maxWidth, + required this.timeMs, + required this.quality, + }); + + String video; + + Map? headers; + + String? thumbnailPath; + + ThumbnailFormat format; + + int maxHeight; + + int maxWidth; + + int timeMs; + + int quality; + + List _toList() { + return [ + video, + headers, + thumbnailPath, + format, + maxHeight, + maxWidth, + timeMs, + quality, + ]; + } + + Object encode() { + return _toList(); + } + + static ThumbnailRequest decode(Object result) { + result as List; + return ThumbnailRequest( + video: result[0]! as String, + headers: (result[1] as Map?)?.cast(), + thumbnailPath: result[2] as String?, + format: result[3]! as ThumbnailFormat, + maxHeight: result[4]! as int, + maxWidth: result[5]! as int, + timeMs: result[6]! as int, + quality: result[7]! as int, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! ThumbnailRequest || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(video, other.video) && + _deepEquals(headers, other.headers) && + _deepEquals(thumbnailPath, other.thumbnailPath) && + _deepEquals(format, other.format) && + _deepEquals(maxHeight, other.maxHeight) && + _deepEquals(maxWidth, other.maxWidth) && + _deepEquals(timeMs, other.timeMs) && + _deepEquals(quality, other.quality); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'ThumbnailRequest(video: $video, headers: $headers, thumbnailPath: $thumbnailPath, format: $format, maxHeight: $maxHeight, maxWidth: $maxWidth, timeMs: $timeMs, quality: $quality)'; + } +} + +class _PigeonCodec extends StandardMessageCodec { + const _PigeonCodec(); + @override + void writeValue(WriteBuffer buffer, Object? value) { + if (value is int) { + buffer.putUint8(4); + buffer.putInt64(value); + } else if (value is ThumbnailFormat) { + buffer.putUint8(129); + writeValue(buffer, value.index); + } else if (value is ThumbnailRequest) { + buffer.putUint8(130); + writeValue(buffer, value.encode()); + } else { + super.writeValue(buffer, value); + } + } + + @override + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + case 129: + final value = readValue(buffer) as int?; + return value == null ? null : ThumbnailFormat.values[value]; + case 130: + return ThumbnailRequest.decode(readValue(buffer)!); + default: + return super.readValueOfType(type, buffer); + } + } +} + +class StreamThumbnailHostApi { + /// Constructor for [StreamThumbnailHostApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + StreamThumbnailHostApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) + : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + final BinaryMessenger? pigeonVar_binaryMessenger; + + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + final String pigeonVar_messageChannelSuffix; + + /// Generates a thumbnail for [ThumbnailRequest.video] and returns its bytes. + Future thumbnailData(ThumbnailRequest request) async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.stream_thumbnail.StreamThumbnailHostApi.thumbnailData$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([request]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ); + return pigeonVar_replyValue! as Uint8List; + } + + /// Generates a thumbnail for [ThumbnailRequest.video] and returns the path + /// it was written to. + Future thumbnailFile(ThumbnailRequest request) async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.stream_thumbnail.StreamThumbnailHostApi.thumbnailFile$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([request]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ); + return pigeonVar_replyValue! as String; + } +} diff --git a/packages/stream_thumbnail/lib/src/stream_thumbnail.dart b/packages/stream_thumbnail/lib/src/stream_thumbnail.dart index eed9dcb3..f6ba4f83 100644 --- a/packages/stream_thumbnail/lib/src/stream_thumbnail.dart +++ b/packages/stream_thumbnail/lib/src/stream_thumbnail.dart @@ -8,6 +8,16 @@ import 'package:flutter/services.dart'; import 'stream_thumbnail_format.dart'; import 'stream_thumbnail_platform.dart'; +/// The file extension the native implementations give a thumbnail of [format]. +/// +/// Kept in sync with `formatExt` (Android), `fileExtension` (iOS/macOS), and +/// `FileExtension` (Windows/Linux). +String _extensionFor(StreamThumbnailFormat format) => switch (format) { + StreamThumbnailFormat.jpeg => 'jpg', + StreamThumbnailFormat.png => 'png', + StreamThumbnailFormat.webp => 'webp', +}; + /// 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`. @@ -17,6 +27,10 @@ abstract final class StreamThumbnail { /// 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. + /// + /// For more than one video, `thumbnailPath` must name a directory rather than + /// a file, otherwise an [ArgumentError] is thrown. Every video in the batch + /// would otherwise be written to that one path. static Future> thumbnailFiles({ required List videos, Map? headers, @@ -29,6 +43,18 @@ abstract final class StreamThumbnail { }) async { if (videos.isEmpty) return []; + // Every platform treats a `thumbnailPath` that already ends in the target + // extension as the exact file to write to. With more than one video that + // points the whole batch at a single path, so the requests overwrite each + // other and the returned files all describe the same bytes. + if (videos.length > 1 && thumbnailPath != null && thumbnailPath.endsWith(_extensionFor(imageFormat))) { + throw ArgumentError.value( + thumbnailPath, + 'thumbnailPath', + 'must be a directory when generating thumbnails for multiple videos, not a single file', + ); + } + return StreamThumbnailPlatform.instance.thumbnailFiles( videos: videos, headers: headers, diff --git a/packages/stream_thumbnail/lib/src/stream_thumbnail_method_channel.dart b/packages/stream_thumbnail/lib/src/stream_thumbnail_method_channel.dart index ed0f8f39..217f00b4 100644 --- a/packages/stream_thumbnail/lib/src/stream_thumbnail_method_channel.dart +++ b/packages/stream_thumbnail/lib/src/stream_thumbnail_method_channel.dart @@ -1,110 +1,58 @@ -// 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 'messages.g.dart'; import 'stream_thumbnail_format.dart'; import 'stream_thumbnail_platform.dart'; -/// An implementation of [StreamThumbnailPlatform] that uses method -/// channels. +/// An implementation of [StreamThumbnailPlatform] that uses a Pigeon-generated +/// platform channel. 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; + /// Constructs a [MethodChannelStreamThumbnail]. + /// + /// [hostApi] is exposed for tests to inject a mock of the generated + /// [StreamThumbnailHostApi]. + MethodChannelStreamThumbnail({StreamThumbnailHostApi? hostApi}) : _hostApi = hostApi ?? StreamThumbnailHostApi(); - _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; + final StreamThumbnailHostApi _hostApi; - _resolveFuture(callId, error is Exception ? error : Exception(error)); - } + int _getTimeMsValue(int? timeMs) => defaultTargetPlatform == TargetPlatform.android ? timeMs ?? -1 : timeMs ?? 0; - void _resolveFuture(int callId, Object value) { - if (value is Exception) { - _futures[callId]?.completeError(value); - } else { - _futures[callId]?.complete(value); + ThumbnailFormat _wireFormat(StreamThumbnailFormat format) { + switch (format) { + case StreamThumbnailFormat.jpeg: + return ThumbnailFormat.jpeg; + case StreamThumbnailFormat.png: + return ThumbnailFormat.png; + case StreamThumbnailFormat.webp: + return ThumbnailFormat.webp; } - _futures.remove(callId); } - (Completer, int) _createCompleterAndCallId() { - final completer = Completer(); - final callId = _nextCallId++; - - _futures[callId] = completer; - - return (completer, callId); + ThumbnailRequest _request({ + required String video, + required Map? headers, + String? thumbnailPath, + required StreamThumbnailFormat imageFormat, + required int maxHeight, + required int maxWidth, + int? timeMs, + required int quality, + }) { + return ThumbnailRequest( + video: video, + headers: headers, + thumbnailPath: thumbnailPath, + format: _wireFormat(imageFormat), + maxHeight: maxHeight, + maxWidth: maxWidth, + timeMs: _getTimeMsValue(timeMs), + quality: quality, + ); } - int _getTimeMsValue(int? timeMs) => defaultTargetPlatform == TargetPlatform.android ? timeMs ?? -1 : timeMs ?? 0; - @override Future> thumbnailFiles({ required List videos, @@ -116,53 +64,24 @@ class MethodChannelStreamThumbnail extends StreamThumbnailPlatform { 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; + 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 completer.future; + return results; } @override @@ -176,33 +95,19 @@ class MethodChannelStreamThumbnail extends StreamThumbnailPlatform { 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; + final path = await _hostApi.thumbnailFile( + _request( + video: video, + headers: headers, + thumbnailPath: thumbnailPath, + imageFormat: imageFormat, + maxHeight: maxHeight, + maxWidth: maxWidth, + timeMs: timeMs, + quality: quality, + ), + ); + return XFile(path); } @override @@ -214,30 +119,17 @@ class MethodChannelStreamThumbnail extends StreamThumbnailPlatform { 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; + }) { + return _hostApi.thumbnailData( + _request( + video: video, + headers: headers, + imageFormat: imageFormat, + maxHeight: maxHeight, + maxWidth: maxWidth, + timeMs: timeMs, + quality: quality, + ), + ); } } diff --git a/packages/stream_thumbnail/linux/CMakeLists.txt b/packages/stream_thumbnail/linux/CMakeLists.txt new file mode 100644 index 00000000..39302435 --- /dev/null +++ b/packages/stream_thumbnail/linux/CMakeLists.txt @@ -0,0 +1,70 @@ +# The Flutter tooling requires that developers have CMake 3.10 or later +# installed. You should not increase this version, as doing so will cause +# the plugin to fail to compile for some customers of the plugin. +cmake_minimum_required(VERSION 3.10) + +# Project-level configuration. +set(PROJECT_NAME "stream_thumbnail") +project(${PROJECT_NAME} LANGUAGES CXX) + +# This value is used when generating builds using this plugin, so it must +# not be changed. +set(PLUGIN_NAME "stream_thumbnail_plugin") + +# Any new source files that you add to the plugin should be added here. +list(APPEND PLUGIN_SOURCES + "stream_thumbnail_plugin.cc" + "pigeon/messages.g.cc" +) + +# Define the plugin library target. Its name must not be changed (see comment +# on PLUGIN_NAME above). +add_library(${PLUGIN_NAME} SHARED + ${PLUGIN_SOURCES} +) + +# Apply a standard set of build settings that are configured in the +# application-level CMakeLists.txt. This can be removed for plugins that want +# full control over build settings. +apply_standard_settings(${PLUGIN_NAME}) + +# Symbols are hidden by default to reduce the chance of accidental conflicts +# between plugins. This should not be removed; any symbols that should be +# exported should be explicitly exported with the FLUTTER_PLUGIN_EXPORT macro. +set_target_properties(${PLUGIN_NAME} PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL) + +# Source include directories and library dependencies. Add any plugin-specific +# dependencies here. +target_include_directories(${PLUGIN_NAME} INTERFACE + "${CMAKE_CURRENT_SOURCE_DIR}/include") +target_link_libraries(${PLUGIN_NAME} PRIVATE flutter) +target_link_libraries(${PLUGIN_NAME} PRIVATE PkgConfig::GTK) + +# Video decode + jpeg/png encode (FFmpeg) and webp encode (libwebp). Requires +# the corresponding "-dev" packages to be installed on the build machine, e.g. +# on Debian/Ubuntu: libavcodec-dev libavformat-dev libavutil-dev +# libswscale-dev libwebp-dev. +find_package(PkgConfig REQUIRED) +pkg_check_modules(AVCODEC REQUIRED IMPORTED_TARGET libavcodec) +pkg_check_modules(AVFORMAT REQUIRED IMPORTED_TARGET libavformat) +pkg_check_modules(AVUTIL REQUIRED IMPORTED_TARGET libavutil) +pkg_check_modules(SWSCALE REQUIRED IMPORTED_TARGET libswscale) +pkg_check_modules(WEBP REQUIRED IMPORTED_TARGET libwebp) + +target_link_libraries(${PLUGIN_NAME} PRIVATE + PkgConfig::AVCODEC + PkgConfig::AVFORMAT + PkgConfig::AVUTIL + PkgConfig::SWSCALE + PkgConfig::WEBP +) + +# List of absolute paths to libraries that should be bundled with the plugin. +# This list could contain prebuilt libraries, or libraries created by an +# external build triggered from this build file. +set(stream_thumbnail_bundled_libraries + "" + PARENT_SCOPE +) diff --git a/packages/stream_thumbnail/linux/include/stream_thumbnail/stream_thumbnail_plugin.h b/packages/stream_thumbnail/linux/include/stream_thumbnail/stream_thumbnail_plugin.h new file mode 100644 index 00000000..1a7933b7 --- /dev/null +++ b/packages/stream_thumbnail/linux/include/stream_thumbnail/stream_thumbnail_plugin.h @@ -0,0 +1,25 @@ +#ifndef FLUTTER_PLUGIN_STREAM_THUMBNAIL_PLUGIN_H_ +#define FLUTTER_PLUGIN_STREAM_THUMBNAIL_PLUGIN_H_ + +#include + +G_BEGIN_DECLS + +#ifdef FLUTTER_PLUGIN_IMPL +#define FLUTTER_PLUGIN_EXPORT __attribute__((visibility("default"))) +#else +#define FLUTTER_PLUGIN_EXPORT +#endif + +typedef struct _StreamThumbnailPlugin StreamThumbnailPlugin; +typedef struct { + GObjectClass parent_class; +} StreamThumbnailPluginClass; + +FLUTTER_PLUGIN_EXPORT GType stream_thumbnail_plugin_get_type(); + +FLUTTER_PLUGIN_EXPORT void stream_thumbnail_plugin_register_with_registrar(FlPluginRegistrar *registrar); + +G_END_DECLS + +#endif // FLUTTER_PLUGIN_STREAM_THUMBNAIL_PLUGIN_H_ diff --git a/packages/stream_thumbnail/linux/pigeon/messages.g.cc b/packages/stream_thumbnail/linux/pigeon/messages.g.cc new file mode 100644 index 00000000..64a681e1 --- /dev/null +++ b/packages/stream_thumbnail/linux/pigeon/messages.g.cc @@ -0,0 +1,811 @@ +// Autogenerated from Pigeon (v27.3.0), do not edit directly. +// See also: https://pub.dev/packages/pigeon + +#include + +#include +#include "messages.g.h" +static guint G_GNUC_UNUSED flpigeon_hash_double(double v) { + if (std::isnan(v)) { + return static_cast(0x7FF80000); + } + if (v == 0.0) { + v = 0.0; + } + union { double d; uint64_t u; } u; + u.d = v; + return static_cast(u.u ^ (u.u >> 32)); +} +static gboolean G_GNUC_UNUSED flpigeon_equals_double(double a, double b) { + return (a == b) || (std::isnan(a) && std::isnan(b)); +} +static gboolean G_GNUC_UNUSED flpigeon_deep_equals(FlValue* a, FlValue* b) { + if (a == b) { + return TRUE; + } + if (a == nullptr || b == nullptr) { + return FALSE; + } + if (fl_value_get_type(a) != fl_value_get_type(b)) { + return FALSE; + } + switch (fl_value_get_type(a)) { + case FL_VALUE_TYPE_NULL: + return TRUE; + case FL_VALUE_TYPE_BOOL: + return fl_value_get_bool(a) == fl_value_get_bool(b); + case FL_VALUE_TYPE_INT: + return fl_value_get_int(a) == fl_value_get_int(b); + case FL_VALUE_TYPE_FLOAT: { + return flpigeon_equals_double(fl_value_get_float(a), fl_value_get_float(b)); + } + case FL_VALUE_TYPE_STRING: + return g_strcmp0(fl_value_get_string(a), fl_value_get_string(b)) == 0; + case FL_VALUE_TYPE_UINT8_LIST: + return fl_value_get_length(a) == fl_value_get_length(b) && + memcmp(fl_value_get_uint8_list(a), fl_value_get_uint8_list(b), fl_value_get_length(a)) == 0; + case FL_VALUE_TYPE_INT32_LIST: + return fl_value_get_length(a) == fl_value_get_length(b) && + memcmp(fl_value_get_int32_list(a), fl_value_get_int32_list(b), fl_value_get_length(a) * sizeof(int32_t)) == 0; + case FL_VALUE_TYPE_INT64_LIST: + return fl_value_get_length(a) == fl_value_get_length(b) && + memcmp(fl_value_get_int64_list(a), fl_value_get_int64_list(b), fl_value_get_length(a) * sizeof(int64_t)) == 0; + case FL_VALUE_TYPE_FLOAT_LIST: { + size_t len = fl_value_get_length(a); + if (len != fl_value_get_length(b)) { + return FALSE; + } + const double* a_data = fl_value_get_float_list(a); + const double* b_data = fl_value_get_float_list(b); + for (size_t i = 0; i < len; i++) { + if (!flpigeon_equals_double(a_data[i], b_data[i])) { + return FALSE; + } + } + return TRUE; + } + case FL_VALUE_TYPE_LIST: { + size_t len = fl_value_get_length(a); + if (len != fl_value_get_length(b)) { + return FALSE; + } + for (size_t i = 0; i < len; i++) { + if (!flpigeon_deep_equals(fl_value_get_list_value(a, i), fl_value_get_list_value(b, i))) { + return FALSE; + } + } + return TRUE; + } + case FL_VALUE_TYPE_MAP: { + size_t len = fl_value_get_length(a); + if (len != fl_value_get_length(b)) { + return FALSE; + } + for (size_t i = 0; i < len; i++) { + FlValue* key = fl_value_get_map_key(a, i); + FlValue* val = fl_value_get_map_value(a, i); + gboolean found = FALSE; + for (size_t j = 0; j < len; j++) { + FlValue* b_key = fl_value_get_map_key(b, j); + if (flpigeon_deep_equals(key, b_key)) { + FlValue* b_val = fl_value_get_map_value(b, j); + if (flpigeon_deep_equals(val, b_val)) { + found = TRUE; + break; + } else { + return FALSE; + } + } + } + if (!found) { + return FALSE; + } + } + return TRUE; + } + default: + return FALSE; + } + return FALSE; +} +static guint G_GNUC_UNUSED flpigeon_deep_hash(FlValue* value) { + if (value == nullptr) { + return 0; + } + switch (fl_value_get_type(value)) { + case FL_VALUE_TYPE_NULL: + return 0; + case FL_VALUE_TYPE_BOOL: + return fl_value_get_bool(value) ? 1231 : 1237; + case FL_VALUE_TYPE_INT: { + int64_t v = fl_value_get_int(value); + return static_cast(v ^ (v >> 32)); + } + case FL_VALUE_TYPE_FLOAT: + return flpigeon_hash_double(fl_value_get_float(value)); + case FL_VALUE_TYPE_STRING: + return g_str_hash(fl_value_get_string(value)); + case FL_VALUE_TYPE_UINT8_LIST: { + guint result = 1; + size_t len = fl_value_get_length(value); + const uint8_t* data = fl_value_get_uint8_list(value); + for (size_t i = 0; i < len; i++) { + result = result * 31 + data[i]; + } + return result; + } + case FL_VALUE_TYPE_INT32_LIST: { + guint result = 1; + size_t len = fl_value_get_length(value); + const int32_t* data = fl_value_get_int32_list(value); + for (size_t i = 0; i < len; i++) { + result = result * 31 + static_cast(data[i]); + } + return result; + } + case FL_VALUE_TYPE_INT64_LIST: { + guint result = 1; + size_t len = fl_value_get_length(value); + const int64_t* data = fl_value_get_int64_list(value); + for (size_t i = 0; i < len; i++) { + result = result * 31 + static_cast(data[i] ^ (data[i] >> 32)); + } + return result; + } + case FL_VALUE_TYPE_FLOAT_LIST: { + guint result = 1; + size_t len = fl_value_get_length(value); + const double* data = fl_value_get_float_list(value); + for (size_t i = 0; i < len; i++) { + result = result * 31 + flpigeon_hash_double(data[i]); + } + return result; + } + case FL_VALUE_TYPE_LIST: { + guint result = 1; + size_t len = fl_value_get_length(value); + for (size_t i = 0; i < len; i++) { + result = result * 31 + flpigeon_deep_hash(fl_value_get_list_value(value, i)); + } + return result; + } + case FL_VALUE_TYPE_MAP: { + guint result = 0; + size_t len = fl_value_get_length(value); + for (size_t i = 0; i < len; i++) { + result += ((flpigeon_deep_hash(fl_value_get_map_key(value, i)) * 31) ^ flpigeon_deep_hash(fl_value_get_map_value(value, i))); + } + return result; + } + default: + return static_cast(fl_value_get_type(value)); + } + return 0; +} +static gchar* G_GNUC_UNUSED flpigeon_to_string(FlValue* value) { + if (value == nullptr) { + return g_strdup("null"); + } + switch (fl_value_get_type(value)) { + case FL_VALUE_TYPE_NULL: + return g_strdup("null"); + case FL_VALUE_TYPE_BOOL: + return g_strdup(fl_value_get_bool(value) ? "true" : "false"); + case FL_VALUE_TYPE_INT: + return g_strdup_printf("%" G_GINT64_FORMAT, fl_value_get_int(value)); + case FL_VALUE_TYPE_FLOAT: + return g_strdup_printf("%g", fl_value_get_float(value)); + case FL_VALUE_TYPE_STRING: + return g_strdup_printf("\"%s\"", fl_value_get_string(value)); + case FL_VALUE_TYPE_UINT8_LIST: { + GString* str = g_string_new("["); + size_t len = fl_value_get_length(value); + const uint8_t* data = fl_value_get_uint8_list(value); + for (size_t i = 0; i < len; i++) { + if (i > 0) { + g_string_append(str, ", "); + } + g_string_append_printf(str, "%d", data[i]); + } + g_string_append(str, "]"); + return g_string_free(str, FALSE); + } + case FL_VALUE_TYPE_INT32_LIST: { + GString* str = g_string_new("["); + size_t len = fl_value_get_length(value); + const int32_t* data = fl_value_get_int32_list(value); + for (size_t i = 0; i < len; i++) { + if (i > 0) { + g_string_append(str, ", "); + } + g_string_append_printf(str, "%d", data[i]); + } + g_string_append(str, "]"); + return g_string_free(str, FALSE); + } + case FL_VALUE_TYPE_INT64_LIST: { + GString* str = g_string_new("["); + size_t len = fl_value_get_length(value); + const int64_t* data = fl_value_get_int64_list(value); + for (size_t i = 0; i < len; i++) { + if (i > 0) { + g_string_append(str, ", "); + } + g_string_append_printf(str, "%" G_GINT64_FORMAT, data[i]); + } + g_string_append(str, "]"); + return g_string_free(str, FALSE); + } + case FL_VALUE_TYPE_FLOAT_LIST: { + GString* str = g_string_new("["); + size_t len = fl_value_get_length(value); + const double* data = fl_value_get_float_list(value); + for (size_t i = 0; i < len; i++) { + if (i > 0) { + g_string_append(str, ", "); + } + g_string_append_printf(str, "%g", data[i]); + } + g_string_append(str, "]"); + return g_string_free(str, FALSE); + } + case FL_VALUE_TYPE_LIST: { + GString* str = g_string_new("["); + size_t len = fl_value_get_length(value); + for (size_t i = 0; i < len; i++) { + if (i > 0) { + g_string_append(str, ", "); + } + gchar* item_str = flpigeon_to_string(fl_value_get_list_value(value, i)); + g_string_append(str, item_str); + g_free(item_str); + } + g_string_append(str, "]"); + return g_string_free(str, FALSE); + } + case FL_VALUE_TYPE_MAP: { + GString* str = g_string_new("{"); + size_t len = fl_value_get_length(value); + for (size_t i = 0; i < len; i++) { + if (i > 0) { + g_string_append(str, ", "); + } + gchar* key_str = flpigeon_to_string(fl_value_get_map_key(value, i)); + gchar* val_str = flpigeon_to_string(fl_value_get_map_value(value, i)); + g_string_append_printf(str, "%s: %s", key_str, val_str); + g_free(key_str); + g_free(val_str); + } + g_string_append(str, "}"); + return g_string_free(str, FALSE); + } + default: + return g_strdup("[custom]"); + } + return g_strdup("null"); +} + + +struct _StreamThumbnailThumbnailRequest { + GObject parent_instance; + + gchar* video; + FlValue* headers; + gchar* thumbnail_path; + StreamThumbnailThumbnailFormat format; + int64_t max_height; + int64_t max_width; + int64_t time_ms; + int64_t quality; +}; + +G_DEFINE_TYPE(StreamThumbnailThumbnailRequest, stream_thumbnail_thumbnail_request, G_TYPE_OBJECT) + +static void stream_thumbnail_thumbnail_request_dispose(GObject* object) { + StreamThumbnailThumbnailRequest* self = STREAM_THUMBNAIL_THUMBNAIL_REQUEST(object); + g_clear_pointer(&self->video, g_free); + g_clear_pointer(&self->headers, fl_value_unref); + g_clear_pointer(&self->thumbnail_path, g_free); + G_OBJECT_CLASS(stream_thumbnail_thumbnail_request_parent_class)->dispose(object); +} + +static void stream_thumbnail_thumbnail_request_init(StreamThumbnailThumbnailRequest* self) { +} + +static void stream_thumbnail_thumbnail_request_class_init(StreamThumbnailThumbnailRequestClass* klass) { + G_OBJECT_CLASS(klass)->dispose = stream_thumbnail_thumbnail_request_dispose; +} + +StreamThumbnailThumbnailRequest* stream_thumbnail_thumbnail_request_new(const gchar* video, FlValue* headers, const gchar* thumbnail_path, StreamThumbnailThumbnailFormat format, int64_t max_height, int64_t max_width, int64_t time_ms, int64_t quality) { + StreamThumbnailThumbnailRequest* self = STREAM_THUMBNAIL_THUMBNAIL_REQUEST(g_object_new(stream_thumbnail_thumbnail_request_get_type(), nullptr)); + self->video = g_strdup(video); + if (headers != nullptr) { + self->headers = fl_value_ref(headers); + } + else { + self->headers = nullptr; + } + if (thumbnail_path != nullptr) { + self->thumbnail_path = g_strdup(thumbnail_path); + } + else { + self->thumbnail_path = nullptr; + } + self->format = format; + self->max_height = max_height; + self->max_width = max_width; + self->time_ms = time_ms; + self->quality = quality; + return self; +} + +const gchar* stream_thumbnail_thumbnail_request_get_video(StreamThumbnailThumbnailRequest* self) { + g_return_val_if_fail(STREAM_THUMBNAIL_IS_THUMBNAIL_REQUEST(self), nullptr); + return self->video; +} + +FlValue* stream_thumbnail_thumbnail_request_get_headers(StreamThumbnailThumbnailRequest* self) { + g_return_val_if_fail(STREAM_THUMBNAIL_IS_THUMBNAIL_REQUEST(self), nullptr); + return self->headers; +} + +const gchar* stream_thumbnail_thumbnail_request_get_thumbnail_path(StreamThumbnailThumbnailRequest* self) { + g_return_val_if_fail(STREAM_THUMBNAIL_IS_THUMBNAIL_REQUEST(self), nullptr); + return self->thumbnail_path; +} + +StreamThumbnailThumbnailFormat stream_thumbnail_thumbnail_request_get_format(StreamThumbnailThumbnailRequest* self) { + g_return_val_if_fail(STREAM_THUMBNAIL_IS_THUMBNAIL_REQUEST(self), static_cast(0)); + return self->format; +} + +int64_t stream_thumbnail_thumbnail_request_get_max_height(StreamThumbnailThumbnailRequest* self) { + g_return_val_if_fail(STREAM_THUMBNAIL_IS_THUMBNAIL_REQUEST(self), 0); + return self->max_height; +} + +int64_t stream_thumbnail_thumbnail_request_get_max_width(StreamThumbnailThumbnailRequest* self) { + g_return_val_if_fail(STREAM_THUMBNAIL_IS_THUMBNAIL_REQUEST(self), 0); + return self->max_width; +} + +int64_t stream_thumbnail_thumbnail_request_get_time_ms(StreamThumbnailThumbnailRequest* self) { + g_return_val_if_fail(STREAM_THUMBNAIL_IS_THUMBNAIL_REQUEST(self), 0); + return self->time_ms; +} + +int64_t stream_thumbnail_thumbnail_request_get_quality(StreamThumbnailThumbnailRequest* self) { + g_return_val_if_fail(STREAM_THUMBNAIL_IS_THUMBNAIL_REQUEST(self), 0); + return self->quality; +} + +static FlValue* stream_thumbnail_thumbnail_request_to_list(StreamThumbnailThumbnailRequest* self) { + FlValue* values = fl_value_new_list(); + fl_value_append_take(values, fl_value_new_string(self->video)); + fl_value_append_take(values, self->headers != nullptr ? fl_value_ref(self->headers) : fl_value_new_null()); + fl_value_append_take(values, self->thumbnail_path != nullptr ? fl_value_new_string(self->thumbnail_path) : fl_value_new_null()); + fl_value_append_take(values, fl_value_new_custom(stream_thumbnail_thumbnail_format_type_id, fl_value_new_int(self->format), (GDestroyNotify)fl_value_unref)); + fl_value_append_take(values, fl_value_new_int(self->max_height)); + fl_value_append_take(values, fl_value_new_int(self->max_width)); + fl_value_append_take(values, fl_value_new_int(self->time_ms)); + fl_value_append_take(values, fl_value_new_int(self->quality)); + return values; +} + +static StreamThumbnailThumbnailRequest* stream_thumbnail_thumbnail_request_new_from_list(FlValue* values) { + FlValue* value0 = fl_value_get_list_value(values, 0); + const gchar* video = fl_value_get_string(value0); + FlValue* value1 = fl_value_get_list_value(values, 1); + FlValue* headers = nullptr; + if (fl_value_get_type(value1) != FL_VALUE_TYPE_NULL) { + headers = value1; + } + FlValue* value2 = fl_value_get_list_value(values, 2); + const gchar* thumbnail_path = nullptr; + if (fl_value_get_type(value2) != FL_VALUE_TYPE_NULL) { + thumbnail_path = fl_value_get_string(value2); + } + FlValue* value3 = fl_value_get_list_value(values, 3); + StreamThumbnailThumbnailFormat format = static_cast(fl_value_get_int(reinterpret_cast(const_cast(fl_value_get_custom_value(value3))))); + FlValue* value4 = fl_value_get_list_value(values, 4); + int64_t max_height = fl_value_get_int(value4); + FlValue* value5 = fl_value_get_list_value(values, 5); + int64_t max_width = fl_value_get_int(value5); + FlValue* value6 = fl_value_get_list_value(values, 6); + int64_t time_ms = fl_value_get_int(value6); + FlValue* value7 = fl_value_get_list_value(values, 7); + int64_t quality = fl_value_get_int(value7); + return stream_thumbnail_thumbnail_request_new(video, headers, thumbnail_path, format, max_height, max_width, time_ms, quality); +} + +gboolean stream_thumbnail_thumbnail_request_equals(StreamThumbnailThumbnailRequest* a, StreamThumbnailThumbnailRequest* b) { + if (a == b) { + return TRUE; + } + if (a == nullptr || b == nullptr) { + return FALSE; + } + if (g_strcmp0(a->video, b->video) != 0) { + return FALSE; + } + if (!flpigeon_deep_equals(a->headers, b->headers)) { + return FALSE; + } + if (g_strcmp0(a->thumbnail_path, b->thumbnail_path) != 0) { + return FALSE; + } + if (a->format != b->format) { + return FALSE; + } + if (a->max_height != b->max_height) { + return FALSE; + } + if (a->max_width != b->max_width) { + return FALSE; + } + if (a->time_ms != b->time_ms) { + return FALSE; + } + if (a->quality != b->quality) { + return FALSE; + } + return TRUE; +} + +guint stream_thumbnail_thumbnail_request_hash(StreamThumbnailThumbnailRequest* self) { + g_return_val_if_fail(STREAM_THUMBNAIL_IS_THUMBNAIL_REQUEST(self), 0); + guint result = 0; + result = result * 31 + (self->video != nullptr ? g_str_hash(self->video) : 0); + result = result * 31 + flpigeon_deep_hash(self->headers); + result = result * 31 + (self->thumbnail_path != nullptr ? g_str_hash(self->thumbnail_path) : 0); + result = result * 31 + static_cast(self->format); + result = result * 31 + static_cast(self->max_height); + result = result * 31 + static_cast(self->max_width); + result = result * 31 + static_cast(self->time_ms); + result = result * 31 + static_cast(self->quality); + return result; +} + +gchar* stream_thumbnail_thumbnail_request_to_string(StreamThumbnailThumbnailRequest* self) { + g_return_val_if_fail(STREAM_THUMBNAIL_IS_THUMBNAIL_REQUEST(self), NULL); + GString* str = g_string_new("ThumbnailRequest("); + g_string_append(str, "video: "); + if (self->video != nullptr) { + g_string_append_printf(str, "\"%s\"", self->video); + } + else { + g_string_append(str, "null"); + } + g_string_append(str, ", headers: "); + if (self->headers != nullptr) { + gchar* val_str = flpigeon_to_string(self->headers); + g_string_append(str, val_str); + g_free(val_str); + } + else { + g_string_append(str, "null"); + } + g_string_append(str, ", thumbnail_path: "); + if (self->thumbnail_path != nullptr) { + g_string_append_printf(str, "\"%s\"", self->thumbnail_path); + } + else { + g_string_append(str, "null"); + } + g_string_append(str, ", format: "); + g_string_append_printf(str, "%d", static_cast(self->format)); + g_string_append(str, ", max_height: "); + g_string_append_printf(str, "%" G_GINT64_FORMAT, self->max_height); + g_string_append(str, ", max_width: "); + g_string_append_printf(str, "%" G_GINT64_FORMAT, self->max_width); + g_string_append(str, ", time_ms: "); + g_string_append_printf(str, "%" G_GINT64_FORMAT, self->time_ms); + g_string_append(str, ", quality: "); + g_string_append_printf(str, "%" G_GINT64_FORMAT, self->quality); + g_string_append(str, ")"); + return g_string_free(str, FALSE); +} + +struct _StreamThumbnailMessageCodec { + FlStandardMessageCodec parent_instance; + +}; + +G_DEFINE_TYPE(StreamThumbnailMessageCodec, stream_thumbnail_message_codec, fl_standard_message_codec_get_type()) + +const int stream_thumbnail_thumbnail_format_type_id = 129; +const int stream_thumbnail_thumbnail_request_type_id = 130; + +static gboolean stream_thumbnail_message_codec_write_stream_thumbnail_thumbnail_format(FlStandardMessageCodec* codec, GByteArray* buffer, FlValue* value, GError** error) { + uint8_t type = stream_thumbnail_thumbnail_format_type_id; + g_byte_array_append(buffer, &type, sizeof(uint8_t)); + return fl_standard_message_codec_write_value(codec, buffer, value, error); +} + +static gboolean stream_thumbnail_message_codec_write_stream_thumbnail_thumbnail_request(FlStandardMessageCodec* codec, GByteArray* buffer, StreamThumbnailThumbnailRequest* value, GError** error) { + uint8_t type = stream_thumbnail_thumbnail_request_type_id; + g_byte_array_append(buffer, &type, sizeof(uint8_t)); + g_autoptr(FlValue) values = stream_thumbnail_thumbnail_request_to_list(value); + return fl_standard_message_codec_write_value(codec, buffer, values, error); +} + +static gboolean stream_thumbnail_message_codec_write_value(FlStandardMessageCodec* codec, GByteArray* buffer, FlValue* value, GError** error) { + if (fl_value_get_type(value) == FL_VALUE_TYPE_CUSTOM) { + switch (fl_value_get_custom_type(value)) { + case stream_thumbnail_thumbnail_format_type_id: + return stream_thumbnail_message_codec_write_stream_thumbnail_thumbnail_format(codec, buffer, reinterpret_cast(const_cast(fl_value_get_custom_value(value))), error); + case stream_thumbnail_thumbnail_request_type_id: + return stream_thumbnail_message_codec_write_stream_thumbnail_thumbnail_request(codec, buffer, STREAM_THUMBNAIL_THUMBNAIL_REQUEST(fl_value_get_custom_value_object(value)), error); + } + } + + return FL_STANDARD_MESSAGE_CODEC_CLASS(stream_thumbnail_message_codec_parent_class)->write_value(codec, buffer, value, error); +} + +static FlValue* stream_thumbnail_message_codec_read_stream_thumbnail_thumbnail_format(FlStandardMessageCodec* codec, GBytes* buffer, size_t* offset, GError** error) { + return fl_value_new_custom(stream_thumbnail_thumbnail_format_type_id, fl_standard_message_codec_read_value(codec, buffer, offset, error), (GDestroyNotify)fl_value_unref); +} + +static FlValue* stream_thumbnail_message_codec_read_stream_thumbnail_thumbnail_request(FlStandardMessageCodec* codec, GBytes* buffer, size_t* offset, GError** error) { + g_autoptr(FlValue) values = fl_standard_message_codec_read_value(codec, buffer, offset, error); + if (values == nullptr) { + return nullptr; + } + + g_autoptr(StreamThumbnailThumbnailRequest) value = stream_thumbnail_thumbnail_request_new_from_list(values); + if (value == nullptr) { + g_set_error(error, FL_MESSAGE_CODEC_ERROR, FL_MESSAGE_CODEC_ERROR_FAILED, "Invalid data received for MessageData"); + return nullptr; + } + + return fl_value_new_custom_object(stream_thumbnail_thumbnail_request_type_id, G_OBJECT(value)); +} + +static FlValue* stream_thumbnail_message_codec_read_value_of_type(FlStandardMessageCodec* codec, GBytes* buffer, size_t* offset, int type, GError** error) { + switch (type) { + case stream_thumbnail_thumbnail_format_type_id: + return stream_thumbnail_message_codec_read_stream_thumbnail_thumbnail_format(codec, buffer, offset, error); + case stream_thumbnail_thumbnail_request_type_id: + return stream_thumbnail_message_codec_read_stream_thumbnail_thumbnail_request(codec, buffer, offset, error); + default: + return FL_STANDARD_MESSAGE_CODEC_CLASS(stream_thumbnail_message_codec_parent_class)->read_value_of_type(codec, buffer, offset, type, error); + } +} + +static void stream_thumbnail_message_codec_init(StreamThumbnailMessageCodec* self) { +} + +static void stream_thumbnail_message_codec_class_init(StreamThumbnailMessageCodecClass* klass) { + FL_STANDARD_MESSAGE_CODEC_CLASS(klass)->write_value = stream_thumbnail_message_codec_write_value; + FL_STANDARD_MESSAGE_CODEC_CLASS(klass)->read_value_of_type = stream_thumbnail_message_codec_read_value_of_type; +} + +static StreamThumbnailMessageCodec* stream_thumbnail_message_codec_new() { + StreamThumbnailMessageCodec* self = STREAM_THUMBNAIL_MESSAGE_CODEC(g_object_new(stream_thumbnail_message_codec_get_type(), nullptr)); + return self; +} + +struct _StreamThumbnailStreamThumbnailHostApiResponseHandle { + GObject parent_instance; + + FlBasicMessageChannel* channel; + FlBasicMessageChannelResponseHandle* response_handle; +}; + +G_DEFINE_TYPE(StreamThumbnailStreamThumbnailHostApiResponseHandle, stream_thumbnail_stream_thumbnail_host_api_response_handle, G_TYPE_OBJECT) + +static void stream_thumbnail_stream_thumbnail_host_api_response_handle_dispose(GObject* object) { + StreamThumbnailStreamThumbnailHostApiResponseHandle* self = STREAM_THUMBNAIL_STREAM_THUMBNAIL_HOST_API_RESPONSE_HANDLE(object); + g_clear_object(&self->channel); + g_clear_object(&self->response_handle); + G_OBJECT_CLASS(stream_thumbnail_stream_thumbnail_host_api_response_handle_parent_class)->dispose(object); +} + +static void stream_thumbnail_stream_thumbnail_host_api_response_handle_init(StreamThumbnailStreamThumbnailHostApiResponseHandle* self) { +} + +static void stream_thumbnail_stream_thumbnail_host_api_response_handle_class_init(StreamThumbnailStreamThumbnailHostApiResponseHandleClass* klass) { + G_OBJECT_CLASS(klass)->dispose = stream_thumbnail_stream_thumbnail_host_api_response_handle_dispose; +} + +static StreamThumbnailStreamThumbnailHostApiResponseHandle* stream_thumbnail_stream_thumbnail_host_api_response_handle_new(FlBasicMessageChannel* channel, FlBasicMessageChannelResponseHandle* response_handle) { + StreamThumbnailStreamThumbnailHostApiResponseHandle* self = STREAM_THUMBNAIL_STREAM_THUMBNAIL_HOST_API_RESPONSE_HANDLE(g_object_new(stream_thumbnail_stream_thumbnail_host_api_response_handle_get_type(), nullptr)); + self->channel = FL_BASIC_MESSAGE_CHANNEL(g_object_ref(channel)); + self->response_handle = FL_BASIC_MESSAGE_CHANNEL_RESPONSE_HANDLE(g_object_ref(response_handle)); + return self; +} + +G_DECLARE_FINAL_TYPE(StreamThumbnailStreamThumbnailHostApiThumbnailDataResponse, stream_thumbnail_stream_thumbnail_host_api_thumbnail_data_response, STREAM_THUMBNAIL, STREAM_THUMBNAIL_HOST_API_THUMBNAIL_DATA_RESPONSE, GObject) + +struct _StreamThumbnailStreamThumbnailHostApiThumbnailDataResponse { + GObject parent_instance; + + FlValue* value; +}; + +G_DEFINE_TYPE(StreamThumbnailStreamThumbnailHostApiThumbnailDataResponse, stream_thumbnail_stream_thumbnail_host_api_thumbnail_data_response, G_TYPE_OBJECT) + +static void stream_thumbnail_stream_thumbnail_host_api_thumbnail_data_response_dispose(GObject* object) { + StreamThumbnailStreamThumbnailHostApiThumbnailDataResponse* self = STREAM_THUMBNAIL_STREAM_THUMBNAIL_HOST_API_THUMBNAIL_DATA_RESPONSE(object); + g_clear_pointer(&self->value, fl_value_unref); + G_OBJECT_CLASS(stream_thumbnail_stream_thumbnail_host_api_thumbnail_data_response_parent_class)->dispose(object); +} + +static void stream_thumbnail_stream_thumbnail_host_api_thumbnail_data_response_init(StreamThumbnailStreamThumbnailHostApiThumbnailDataResponse* self) { +} + +static void stream_thumbnail_stream_thumbnail_host_api_thumbnail_data_response_class_init(StreamThumbnailStreamThumbnailHostApiThumbnailDataResponseClass* klass) { + G_OBJECT_CLASS(klass)->dispose = stream_thumbnail_stream_thumbnail_host_api_thumbnail_data_response_dispose; +} + +static StreamThumbnailStreamThumbnailHostApiThumbnailDataResponse* stream_thumbnail_stream_thumbnail_host_api_thumbnail_data_response_new(const uint8_t* return_value, size_t return_value_length) { + StreamThumbnailStreamThumbnailHostApiThumbnailDataResponse* self = STREAM_THUMBNAIL_STREAM_THUMBNAIL_HOST_API_THUMBNAIL_DATA_RESPONSE(g_object_new(stream_thumbnail_stream_thumbnail_host_api_thumbnail_data_response_get_type(), nullptr)); + self->value = fl_value_new_list(); + fl_value_append_take(self->value, fl_value_new_uint8_list(return_value, return_value_length)); + return self; +} + +static StreamThumbnailStreamThumbnailHostApiThumbnailDataResponse* stream_thumbnail_stream_thumbnail_host_api_thumbnail_data_response_new_error(const gchar* code, const gchar* message, FlValue* details) { + StreamThumbnailStreamThumbnailHostApiThumbnailDataResponse* self = STREAM_THUMBNAIL_STREAM_THUMBNAIL_HOST_API_THUMBNAIL_DATA_RESPONSE(g_object_new(stream_thumbnail_stream_thumbnail_host_api_thumbnail_data_response_get_type(), nullptr)); + self->value = fl_value_new_list(); + fl_value_append_take(self->value, fl_value_new_string(code)); + fl_value_append_take(self->value, fl_value_new_string(message != nullptr ? message : "")); + fl_value_append_take(self->value, details != nullptr ? fl_value_ref(details) : fl_value_new_null()); + return self; +} + +G_DECLARE_FINAL_TYPE(StreamThumbnailStreamThumbnailHostApiThumbnailFileResponse, stream_thumbnail_stream_thumbnail_host_api_thumbnail_file_response, STREAM_THUMBNAIL, STREAM_THUMBNAIL_HOST_API_THUMBNAIL_FILE_RESPONSE, GObject) + +struct _StreamThumbnailStreamThumbnailHostApiThumbnailFileResponse { + GObject parent_instance; + + FlValue* value; +}; + +G_DEFINE_TYPE(StreamThumbnailStreamThumbnailHostApiThumbnailFileResponse, stream_thumbnail_stream_thumbnail_host_api_thumbnail_file_response, G_TYPE_OBJECT) + +static void stream_thumbnail_stream_thumbnail_host_api_thumbnail_file_response_dispose(GObject* object) { + StreamThumbnailStreamThumbnailHostApiThumbnailFileResponse* self = STREAM_THUMBNAIL_STREAM_THUMBNAIL_HOST_API_THUMBNAIL_FILE_RESPONSE(object); + g_clear_pointer(&self->value, fl_value_unref); + G_OBJECT_CLASS(stream_thumbnail_stream_thumbnail_host_api_thumbnail_file_response_parent_class)->dispose(object); +} + +static void stream_thumbnail_stream_thumbnail_host_api_thumbnail_file_response_init(StreamThumbnailStreamThumbnailHostApiThumbnailFileResponse* self) { +} + +static void stream_thumbnail_stream_thumbnail_host_api_thumbnail_file_response_class_init(StreamThumbnailStreamThumbnailHostApiThumbnailFileResponseClass* klass) { + G_OBJECT_CLASS(klass)->dispose = stream_thumbnail_stream_thumbnail_host_api_thumbnail_file_response_dispose; +} + +static StreamThumbnailStreamThumbnailHostApiThumbnailFileResponse* stream_thumbnail_stream_thumbnail_host_api_thumbnail_file_response_new(const gchar* return_value) { + StreamThumbnailStreamThumbnailHostApiThumbnailFileResponse* self = STREAM_THUMBNAIL_STREAM_THUMBNAIL_HOST_API_THUMBNAIL_FILE_RESPONSE(g_object_new(stream_thumbnail_stream_thumbnail_host_api_thumbnail_file_response_get_type(), nullptr)); + self->value = fl_value_new_list(); + fl_value_append_take(self->value, fl_value_new_string(return_value)); + return self; +} + +static StreamThumbnailStreamThumbnailHostApiThumbnailFileResponse* stream_thumbnail_stream_thumbnail_host_api_thumbnail_file_response_new_error(const gchar* code, const gchar* message, FlValue* details) { + StreamThumbnailStreamThumbnailHostApiThumbnailFileResponse* self = STREAM_THUMBNAIL_STREAM_THUMBNAIL_HOST_API_THUMBNAIL_FILE_RESPONSE(g_object_new(stream_thumbnail_stream_thumbnail_host_api_thumbnail_file_response_get_type(), nullptr)); + self->value = fl_value_new_list(); + fl_value_append_take(self->value, fl_value_new_string(code)); + fl_value_append_take(self->value, fl_value_new_string(message != nullptr ? message : "")); + fl_value_append_take(self->value, details != nullptr ? fl_value_ref(details) : fl_value_new_null()); + return self; +} + +struct _StreamThumbnailStreamThumbnailHostApi { + GObject parent_instance; + + const StreamThumbnailStreamThumbnailHostApiVTable* vtable; + gpointer user_data; + GDestroyNotify user_data_free_func; +}; + +G_DEFINE_TYPE(StreamThumbnailStreamThumbnailHostApi, stream_thumbnail_stream_thumbnail_host_api, G_TYPE_OBJECT) + +static void stream_thumbnail_stream_thumbnail_host_api_dispose(GObject* object) { + StreamThumbnailStreamThumbnailHostApi* self = STREAM_THUMBNAIL_STREAM_THUMBNAIL_HOST_API(object); + if (self->user_data != nullptr) { + self->user_data_free_func(self->user_data); + } + self->user_data = nullptr; + G_OBJECT_CLASS(stream_thumbnail_stream_thumbnail_host_api_parent_class)->dispose(object); +} + +static void stream_thumbnail_stream_thumbnail_host_api_init(StreamThumbnailStreamThumbnailHostApi* self) { +} + +static void stream_thumbnail_stream_thumbnail_host_api_class_init(StreamThumbnailStreamThumbnailHostApiClass* klass) { + G_OBJECT_CLASS(klass)->dispose = stream_thumbnail_stream_thumbnail_host_api_dispose; +} + +static StreamThumbnailStreamThumbnailHostApi* stream_thumbnail_stream_thumbnail_host_api_new(const StreamThumbnailStreamThumbnailHostApiVTable* vtable, gpointer user_data, GDestroyNotify user_data_free_func) { + StreamThumbnailStreamThumbnailHostApi* self = STREAM_THUMBNAIL_STREAM_THUMBNAIL_HOST_API(g_object_new(stream_thumbnail_stream_thumbnail_host_api_get_type(), nullptr)); + self->vtable = vtable; + self->user_data = user_data; + self->user_data_free_func = user_data_free_func; + return self; +} + +static void stream_thumbnail_stream_thumbnail_host_api_thumbnail_data_cb(FlBasicMessageChannel* channel, FlValue* message_, FlBasicMessageChannelResponseHandle* response_handle, gpointer user_data) { + StreamThumbnailStreamThumbnailHostApi* self = STREAM_THUMBNAIL_STREAM_THUMBNAIL_HOST_API(user_data); + + if (self->vtable == nullptr || self->vtable->thumbnail_data == nullptr) { + return; + } + + FlValue* value0 = fl_value_get_list_value(message_, 0); + StreamThumbnailThumbnailRequest* request = STREAM_THUMBNAIL_THUMBNAIL_REQUEST(fl_value_get_custom_value_object(value0)); + g_autoptr(StreamThumbnailStreamThumbnailHostApiResponseHandle) handle = stream_thumbnail_stream_thumbnail_host_api_response_handle_new(channel, response_handle); + self->vtable->thumbnail_data(request, handle, self->user_data); +} + +static void stream_thumbnail_stream_thumbnail_host_api_thumbnail_file_cb(FlBasicMessageChannel* channel, FlValue* message_, FlBasicMessageChannelResponseHandle* response_handle, gpointer user_data) { + StreamThumbnailStreamThumbnailHostApi* self = STREAM_THUMBNAIL_STREAM_THUMBNAIL_HOST_API(user_data); + + if (self->vtable == nullptr || self->vtable->thumbnail_file == nullptr) { + return; + } + + FlValue* value0 = fl_value_get_list_value(message_, 0); + StreamThumbnailThumbnailRequest* request = STREAM_THUMBNAIL_THUMBNAIL_REQUEST(fl_value_get_custom_value_object(value0)); + g_autoptr(StreamThumbnailStreamThumbnailHostApiResponseHandle) handle = stream_thumbnail_stream_thumbnail_host_api_response_handle_new(channel, response_handle); + self->vtable->thumbnail_file(request, handle, self->user_data); +} + +void stream_thumbnail_stream_thumbnail_host_api_set_method_handlers(FlBinaryMessenger* messenger, const gchar* suffix, const StreamThumbnailStreamThumbnailHostApiVTable* vtable, gpointer user_data, GDestroyNotify user_data_free_func) { + g_autofree gchar* dot_suffix = suffix != nullptr ? g_strdup_printf(".%s", suffix) : g_strdup(""); + g_autoptr(StreamThumbnailStreamThumbnailHostApi) api_data = stream_thumbnail_stream_thumbnail_host_api_new(vtable, user_data, user_data_free_func); + + g_autoptr(StreamThumbnailMessageCodec) codec = stream_thumbnail_message_codec_new(); + g_autofree gchar* thumbnail_data_channel_name = g_strdup_printf("dev.flutter.pigeon.stream_thumbnail.StreamThumbnailHostApi.thumbnailData%s", dot_suffix); + g_autoptr(FlBasicMessageChannel) thumbnail_data_channel = fl_basic_message_channel_new(messenger, thumbnail_data_channel_name, FL_MESSAGE_CODEC(codec)); + fl_basic_message_channel_set_message_handler(thumbnail_data_channel, stream_thumbnail_stream_thumbnail_host_api_thumbnail_data_cb, g_object_ref(api_data), g_object_unref); + g_autofree gchar* thumbnail_file_channel_name = g_strdup_printf("dev.flutter.pigeon.stream_thumbnail.StreamThumbnailHostApi.thumbnailFile%s", dot_suffix); + g_autoptr(FlBasicMessageChannel) thumbnail_file_channel = fl_basic_message_channel_new(messenger, thumbnail_file_channel_name, FL_MESSAGE_CODEC(codec)); + fl_basic_message_channel_set_message_handler(thumbnail_file_channel, stream_thumbnail_stream_thumbnail_host_api_thumbnail_file_cb, g_object_ref(api_data), g_object_unref); +} + +void stream_thumbnail_stream_thumbnail_host_api_clear_method_handlers(FlBinaryMessenger* messenger, const gchar* suffix) { + g_autofree gchar* dot_suffix = suffix != nullptr ? g_strdup_printf(".%s", suffix) : g_strdup(""); + + g_autoptr(StreamThumbnailMessageCodec) codec = stream_thumbnail_message_codec_new(); + g_autofree gchar* thumbnail_data_channel_name = g_strdup_printf("dev.flutter.pigeon.stream_thumbnail.StreamThumbnailHostApi.thumbnailData%s", dot_suffix); + g_autoptr(FlBasicMessageChannel) thumbnail_data_channel = fl_basic_message_channel_new(messenger, thumbnail_data_channel_name, FL_MESSAGE_CODEC(codec)); + fl_basic_message_channel_set_message_handler(thumbnail_data_channel, nullptr, nullptr, nullptr); + g_autofree gchar* thumbnail_file_channel_name = g_strdup_printf("dev.flutter.pigeon.stream_thumbnail.StreamThumbnailHostApi.thumbnailFile%s", dot_suffix); + g_autoptr(FlBasicMessageChannel) thumbnail_file_channel = fl_basic_message_channel_new(messenger, thumbnail_file_channel_name, FL_MESSAGE_CODEC(codec)); + fl_basic_message_channel_set_message_handler(thumbnail_file_channel, nullptr, nullptr, nullptr); +} + +void stream_thumbnail_stream_thumbnail_host_api_respond_thumbnail_data(StreamThumbnailStreamThumbnailHostApiResponseHandle* response_handle, const uint8_t* return_value, size_t return_value_length) { + g_autoptr(StreamThumbnailStreamThumbnailHostApiThumbnailDataResponse) response = stream_thumbnail_stream_thumbnail_host_api_thumbnail_data_response_new(return_value, return_value_length); + g_autoptr(GError) error = nullptr; + if (!fl_basic_message_channel_respond(response_handle->channel, response_handle->response_handle, response->value, &error)) { + g_warning("Failed to send response to %s.%s: %s", "StreamThumbnailHostApi", "thumbnailData", error->message); + } +} + +void stream_thumbnail_stream_thumbnail_host_api_respond_error_thumbnail_data(StreamThumbnailStreamThumbnailHostApiResponseHandle* response_handle, const gchar* code, const gchar* message, FlValue* details) { + g_autoptr(StreamThumbnailStreamThumbnailHostApiThumbnailDataResponse) response = stream_thumbnail_stream_thumbnail_host_api_thumbnail_data_response_new_error(code, message, details); + g_autoptr(GError) error = nullptr; + if (!fl_basic_message_channel_respond(response_handle->channel, response_handle->response_handle, response->value, &error)) { + g_warning("Failed to send response to %s.%s: %s", "StreamThumbnailHostApi", "thumbnailData", error->message); + } +} + +void stream_thumbnail_stream_thumbnail_host_api_respond_thumbnail_file(StreamThumbnailStreamThumbnailHostApiResponseHandle* response_handle, const gchar* return_value) { + g_autoptr(StreamThumbnailStreamThumbnailHostApiThumbnailFileResponse) response = stream_thumbnail_stream_thumbnail_host_api_thumbnail_file_response_new(return_value); + g_autoptr(GError) error = nullptr; + if (!fl_basic_message_channel_respond(response_handle->channel, response_handle->response_handle, response->value, &error)) { + g_warning("Failed to send response to %s.%s: %s", "StreamThumbnailHostApi", "thumbnailFile", error->message); + } +} + +void stream_thumbnail_stream_thumbnail_host_api_respond_error_thumbnail_file(StreamThumbnailStreamThumbnailHostApiResponseHandle* response_handle, const gchar* code, const gchar* message, FlValue* details) { + g_autoptr(StreamThumbnailStreamThumbnailHostApiThumbnailFileResponse) response = stream_thumbnail_stream_thumbnail_host_api_thumbnail_file_response_new_error(code, message, details); + g_autoptr(GError) error = nullptr; + if (!fl_basic_message_channel_respond(response_handle->channel, response_handle->response_handle, response->value, &error)) { + g_warning("Failed to send response to %s.%s: %s", "StreamThumbnailHostApi", "thumbnailFile", error->message); + } +} diff --git a/packages/stream_thumbnail/linux/pigeon/messages.g.h b/packages/stream_thumbnail/linux/pigeon/messages.g.h new file mode 100644 index 00000000..dca35a33 --- /dev/null +++ b/packages/stream_thumbnail/linux/pigeon/messages.g.h @@ -0,0 +1,253 @@ +// Autogenerated from Pigeon (v27.3.0), do not edit directly. +// See also: https://pub.dev/packages/pigeon + +#ifndef PIGEON_MESSAGES_G_H_ +#define PIGEON_MESSAGES_G_H_ + +#include + +G_BEGIN_DECLS + +/** + * StreamThumbnailThumbnailFormat: + * STREAM_THUMBNAIL_THUMBNAIL_FORMAT_JPEG: + * STREAM_THUMBNAIL_THUMBNAIL_FORMAT_PNG: + * STREAM_THUMBNAIL_THUMBNAIL_FORMAT_WEBP: + * + * Wire representation of the image format for a generated thumbnail. + */ +typedef enum { + STREAM_THUMBNAIL_THUMBNAIL_FORMAT_JPEG = 0, + STREAM_THUMBNAIL_THUMBNAIL_FORMAT_PNG = 1, + STREAM_THUMBNAIL_THUMBNAIL_FORMAT_WEBP = 2 +} StreamThumbnailThumbnailFormat; + +/** + * StreamThumbnailThumbnailRequest: + * + * A single thumbnail generation request sent to the native platform. + */ + +G_DECLARE_FINAL_TYPE(StreamThumbnailThumbnailRequest, stream_thumbnail_thumbnail_request, STREAM_THUMBNAIL, THUMBNAIL_REQUEST, GObject) + +/** + * stream_thumbnail_thumbnail_request_new: + * video: field in this object. + * headers: field in this object. + * thumbnail_path: field in this object. + * format: field in this object. + * max_height: field in this object. + * max_width: field in this object. + * time_ms: field in this object. + * quality: field in this object. + * + * Creates a new #ThumbnailRequest object. + * + * Returns: a new #StreamThumbnailThumbnailRequest + */ +StreamThumbnailThumbnailRequest* stream_thumbnail_thumbnail_request_new(const gchar* video, FlValue* headers, const gchar* thumbnail_path, StreamThumbnailThumbnailFormat format, int64_t max_height, int64_t max_width, int64_t time_ms, int64_t quality); + +/** + * stream_thumbnail_thumbnail_request_get_video + * @object: a #StreamThumbnailThumbnailRequest. + * + * Gets the value of the video field of @object. + * + * Returns: the field value. + */ +const gchar* stream_thumbnail_thumbnail_request_get_video(StreamThumbnailThumbnailRequest* object); + +/** + * stream_thumbnail_thumbnail_request_get_headers + * @object: a #StreamThumbnailThumbnailRequest. + * + * Gets the value of the headers field of @object. + * + * Returns: the field value. + */ +FlValue* stream_thumbnail_thumbnail_request_get_headers(StreamThumbnailThumbnailRequest* object); + +/** + * stream_thumbnail_thumbnail_request_get_thumbnail_path + * @object: a #StreamThumbnailThumbnailRequest. + * + * Gets the value of the thumbnailPath field of @object. + * + * Returns: the field value. + */ +const gchar* stream_thumbnail_thumbnail_request_get_thumbnail_path(StreamThumbnailThumbnailRequest* object); + +/** + * stream_thumbnail_thumbnail_request_get_format + * @object: a #StreamThumbnailThumbnailRequest. + * + * Gets the value of the format field of @object. + * + * Returns: the field value. + */ +StreamThumbnailThumbnailFormat stream_thumbnail_thumbnail_request_get_format(StreamThumbnailThumbnailRequest* object); + +/** + * stream_thumbnail_thumbnail_request_get_max_height + * @object: a #StreamThumbnailThumbnailRequest. + * + * Gets the value of the maxHeight field of @object. + * + * Returns: the field value. + */ +int64_t stream_thumbnail_thumbnail_request_get_max_height(StreamThumbnailThumbnailRequest* object); + +/** + * stream_thumbnail_thumbnail_request_get_max_width + * @object: a #StreamThumbnailThumbnailRequest. + * + * Gets the value of the maxWidth field of @object. + * + * Returns: the field value. + */ +int64_t stream_thumbnail_thumbnail_request_get_max_width(StreamThumbnailThumbnailRequest* object); + +/** + * stream_thumbnail_thumbnail_request_get_time_ms + * @object: a #StreamThumbnailThumbnailRequest. + * + * Gets the value of the timeMs field of @object. + * + * Returns: the field value. + */ +int64_t stream_thumbnail_thumbnail_request_get_time_ms(StreamThumbnailThumbnailRequest* object); + +/** + * stream_thumbnail_thumbnail_request_get_quality + * @object: a #StreamThumbnailThumbnailRequest. + * + * Gets the value of the quality field of @object. + * + * Returns: the field value. + */ +int64_t stream_thumbnail_thumbnail_request_get_quality(StreamThumbnailThumbnailRequest* object); + +/** + * stream_thumbnail_thumbnail_request_equals: + * @a: a #StreamThumbnailThumbnailRequest. + * @b: another #StreamThumbnailThumbnailRequest. + * + * Checks if two #StreamThumbnailThumbnailRequest objects are equal. + * + * Returns: TRUE if @a and @b are equal. + */ +gboolean stream_thumbnail_thumbnail_request_equals(StreamThumbnailThumbnailRequest* a, StreamThumbnailThumbnailRequest* b); + +/** + * stream_thumbnail_thumbnail_request_hash: + * @object: a #StreamThumbnailThumbnailRequest. + * + * Calculates a hash code for a #StreamThumbnailThumbnailRequest object. + * + * Returns: the hash code. + */ +guint stream_thumbnail_thumbnail_request_hash(StreamThumbnailThumbnailRequest* object); + +/** + * stream_thumbnail_thumbnail_request_to_string: + * @object: a #StreamThumbnailThumbnailRequest. + * + * Returns a string representation of a #StreamThumbnailThumbnailRequest object. + * + * Returns: (transfer full): a new string, free with g_free(). + */ +gchar* stream_thumbnail_thumbnail_request_to_string(StreamThumbnailThumbnailRequest* object); + +G_DECLARE_FINAL_TYPE(StreamThumbnailMessageCodec, stream_thumbnail_message_codec, STREAM_THUMBNAIL, MESSAGE_CODEC, FlStandardMessageCodec) + +/** + * Custom type ID constants: + * + * Constants used to identify custom types in the codec. + * They are used in the codec to encode and decode custom types. + * They may be used in custom object creation functions to identify the type. + */ +extern const int stream_thumbnail_thumbnail_format_type_id; +extern const int stream_thumbnail_thumbnail_request_type_id; + +G_DECLARE_FINAL_TYPE(StreamThumbnailStreamThumbnailHostApi, stream_thumbnail_stream_thumbnail_host_api, STREAM_THUMBNAIL, STREAM_THUMBNAIL_HOST_API, GObject) + +G_DECLARE_FINAL_TYPE(StreamThumbnailStreamThumbnailHostApiResponseHandle, stream_thumbnail_stream_thumbnail_host_api_response_handle, STREAM_THUMBNAIL, STREAM_THUMBNAIL_HOST_API_RESPONSE_HANDLE, GObject) + +/** + * StreamThumbnailStreamThumbnailHostApiVTable: + * + * Table of functions exposed by StreamThumbnailHostApi to be implemented by the API provider. + */ +typedef struct { + void (*thumbnail_data)(StreamThumbnailThumbnailRequest* request, StreamThumbnailStreamThumbnailHostApiResponseHandle* response_handle, gpointer user_data); + void (*thumbnail_file)(StreamThumbnailThumbnailRequest* request, StreamThumbnailStreamThumbnailHostApiResponseHandle* response_handle, gpointer user_data); +} StreamThumbnailStreamThumbnailHostApiVTable; + +/** + * stream_thumbnail_stream_thumbnail_host_api_set_method_handlers: + * + * @messenger: an #FlBinaryMessenger. + * @suffix: (allow-none): a suffix to add to the API or %NULL for none. + * @vtable: implementations of the methods in this API. + * @user_data: (closure): user data to pass to the functions in @vtable. + * @user_data_free_func: (allow-none): a function which gets called to free @user_data, or %NULL. + * + * Connects the method handlers in the StreamThumbnailHostApi API. + */ +void stream_thumbnail_stream_thumbnail_host_api_set_method_handlers(FlBinaryMessenger* messenger, const gchar* suffix, const StreamThumbnailStreamThumbnailHostApiVTable* vtable, gpointer user_data, GDestroyNotify user_data_free_func); + +/** + * stream_thumbnail_stream_thumbnail_host_api_clear_method_handlers: + * + * @messenger: an #FlBinaryMessenger. + * @suffix: (allow-none): a suffix to add to the API or %NULL for none. + * + * Clears the method handlers in the StreamThumbnailHostApi API. + */ +void stream_thumbnail_stream_thumbnail_host_api_clear_method_handlers(FlBinaryMessenger* messenger, const gchar* suffix); + +/** + * stream_thumbnail_stream_thumbnail_host_api_respond_thumbnail_data: + * @response_handle: a #StreamThumbnailStreamThumbnailHostApiResponseHandle. + * @return_value: location to write the value returned by this method. + * @return_value_length: (allow-none): location to write length of @return_value or %NULL to ignore. + * + * Responds to StreamThumbnailHostApi.thumbnailData. + */ +void stream_thumbnail_stream_thumbnail_host_api_respond_thumbnail_data(StreamThumbnailStreamThumbnailHostApiResponseHandle* response_handle, const uint8_t* return_value, size_t return_value_length); + +/** + * stream_thumbnail_stream_thumbnail_host_api_respond_error_thumbnail_data: + * @response_handle: a #StreamThumbnailStreamThumbnailHostApiResponseHandle. + * @code: error code. + * @message: error message. + * @details: (allow-none): error details or %NULL. + * + * Responds with an error to StreamThumbnailHostApi.thumbnailData. + */ +void stream_thumbnail_stream_thumbnail_host_api_respond_error_thumbnail_data(StreamThumbnailStreamThumbnailHostApiResponseHandle* response_handle, const gchar* code, const gchar* message, FlValue* details); + +/** + * stream_thumbnail_stream_thumbnail_host_api_respond_thumbnail_file: + * @response_handle: a #StreamThumbnailStreamThumbnailHostApiResponseHandle. + * @return_value: location to write the value returned by this method. + * + * Responds to StreamThumbnailHostApi.thumbnailFile. + */ +void stream_thumbnail_stream_thumbnail_host_api_respond_thumbnail_file(StreamThumbnailStreamThumbnailHostApiResponseHandle* response_handle, const gchar* return_value); + +/** + * stream_thumbnail_stream_thumbnail_host_api_respond_error_thumbnail_file: + * @response_handle: a #StreamThumbnailStreamThumbnailHostApiResponseHandle. + * @code: error code. + * @message: error message. + * @details: (allow-none): error details or %NULL. + * + * Responds with an error to StreamThumbnailHostApi.thumbnailFile. + */ +void stream_thumbnail_stream_thumbnail_host_api_respond_error_thumbnail_file(StreamThumbnailStreamThumbnailHostApiResponseHandle* response_handle, const gchar* code, const gchar* message, FlValue* details); + +G_END_DECLS + +#endif // PIGEON_MESSAGES_G_H_ diff --git a/packages/stream_thumbnail/linux/stream_thumbnail_plugin.cc b/packages/stream_thumbnail/linux/stream_thumbnail_plugin.cc new file mode 100644 index 00000000..3a24f2e5 --- /dev/null +++ b/packages/stream_thumbnail/linux/stream_thumbnail_plugin.cc @@ -0,0 +1,526 @@ +#include "include/stream_thumbnail/stream_thumbnail_plugin.h" + +extern "C" { +#include +#include +#include +#include +#include +} +#include + +#include + +#include +#include +#include +#include +#include + +#include "pigeon/messages.g.h" + +#define STREAM_THUMBNAIL_PLUGIN(obj) \ + (G_TYPE_CHECK_INSTANCE_CAST((obj), stream_thumbnail_plugin_get_type(), StreamThumbnailPlugin)) + +struct _StreamThumbnailPlugin { + GObject parent_instance; +}; + +G_DEFINE_TYPE(StreamThumbnailPlugin, stream_thumbnail_plugin, g_object_get_type()) + +namespace { + +// A generation failure that couldn't produce a frame/encode, or a failure +// writing the encoded thumbnail to disk. +class ThumbnailException : public std::runtime_error { + public: + ThumbnailException(std::string code, const std::string &message) + : std::runtime_error(message), code_(std::move(code)) {} + const std::string &code() const { return code_; } + + private: + std::string code_; +}; + +struct FormatContextDeleter { + void operator()(AVFormatContext *ctx) const { avformat_close_input(&ctx); } +}; +struct CodecContextDeleter { + void operator()(AVCodecContext *ctx) const { avcodec_free_context(&ctx); } +}; +struct FrameDeleter { + void operator()(AVFrame *frame) const { av_frame_free(&frame); } +}; +struct PacketDeleter { + void operator()(AVPacket *packet) const { av_packet_free(&packet); } +}; +struct SwsContextDeleter { + void operator()(SwsContext *ctx) const { sws_freeContext(ctx); } +}; + +using FormatContextPtr = std::unique_ptr; +using CodecContextPtr = std::unique_ptr; +using FramePtr = std::unique_ptr; +using PacketPtr = std::unique_ptr; +using SwsContextPtr = std::unique_ptr; + +bool IsLocalPath(const std::string &video) { + return video.rfind("/", 0) == 0 || video.rfind("file://", 0) == 0; +} + +// True only for an explicit non-file URL scheme, e.g. "https://host/clip.mp4". +// Bare absolute and relative paths are local. +bool IsRemoteUrl(const std::string &video) { + const size_t scheme = video.find("://"); + return scheme != std::string::npos && video.compare(0, scheme, "file") != 0; +} + +// Strips a leading "file://" prefix, if present. +std::string VideoPath(const std::string &video) { + if (video.rfind("file://", 0) == 0) return video.substr(7); + return video; +} + +std::string FileExtension(StreamThumbnailThumbnailFormat format) { + switch (format) { + case STREAM_THUMBNAIL_THUMBNAIL_FORMAT_JPEG: + return "jpg"; + case STREAM_THUMBNAIL_THUMBNAIL_FORMAT_PNG: + return "png"; + case STREAM_THUMBNAIL_THUMBNAIL_FORMAT_WEBP: + return "webp"; + } + return "jpg"; +} + +// Decodes a single frame from `video` at `time_ms`, at its native resolution +// and pixel format. Unlike Media Foundation (Windows), FFmpeg's http(s) +// protocol handler has a simple, direct way to attach custom headers. +FramePtr DecodeFrame(const std::string &video, const std::map *headers, int64_t time_ms) { + AVDictionary *options = nullptr; + if (headers != nullptr && !headers->empty()) { + std::string header_lines; + for (const auto &[key, value] : *headers) { + header_lines += key + ": " + value + "\r\n"; + } + av_dict_set(&options, "headers", header_lines.c_str(), 0); + } + + // FFmpeg would otherwise happily open any protocol it was built with, so a + // crafted path could reach handlers like `subfile:`/`concat:`, and a remote + // playlist could nest a `file:` open to read local files. Whitelist only + // what thumbnailing actually needs — including the subprotocols https + // delegates to (tls -> tcp) — and allow `file` only for local inputs. + const std::string protocols = IsRemoteUrl(video) ? "http,https,tls,tcp" : "file,http,https,tls,tcp"; + av_dict_set(&options, "protocol_whitelist", protocols.c_str(), 0); + + AVFormatContext *raw_format_ctx = nullptr; + const int open_result = avformat_open_input(&raw_format_ctx, video.c_str(), nullptr, &options); + av_dict_free(&options); + if (open_result < 0) { + throw ThumbnailException("THUMBNAIL_ERROR", "Failed to open the video."); + } + FormatContextPtr format_ctx(raw_format_ctx); + + if (avformat_find_stream_info(format_ctx.get(), nullptr) < 0) { + throw ThumbnailException("THUMBNAIL_ERROR", "Failed to read the video's stream info."); + } + + const AVCodec *decoder = nullptr; + const int stream_index = av_find_best_stream(format_ctx.get(), AVMEDIA_TYPE_VIDEO, -1, -1, &decoder, 0); + if (stream_index < 0 || decoder == nullptr) { + throw ThumbnailException("THUMBNAIL_ERROR", "Failed to find a video stream."); + } + + AVStream *stream = format_ctx->streams[stream_index]; + CodecContextPtr codec_ctx(avcodec_alloc_context3(decoder)); + if (!codec_ctx || avcodec_parameters_to_context(codec_ctx.get(), stream->codecpar) < 0) { + throw ThumbnailException("THUMBNAIL_ERROR", "Failed to configure the video decoder."); + } + if (avcodec_open2(codec_ctx.get(), decoder, nullptr) < 0) { + throw ThumbnailException("THUMBNAIL_ERROR", "Failed to open the video decoder."); + } + + if (time_ms > 0) { + const int64_t timestamp = av_rescale_q(time_ms, AVRational{1, 1000}, stream->time_base); + av_seek_frame(format_ctx.get(), stream_index, timestamp, AVSEEK_FLAG_BACKWARD); + } + + PacketPtr packet(av_packet_alloc()); + FramePtr frame(av_frame_alloc()); + bool have_frame = false; + + while (!have_frame && av_read_frame(format_ctx.get(), packet.get()) >= 0) { + if (packet->stream_index == stream_index && avcodec_send_packet(codec_ctx.get(), packet.get()) == 0) { + if (avcodec_receive_frame(codec_ctx.get(), frame.get()) == 0) { + have_frame = true; + } + } + av_packet_unref(packet.get()); + } + if (!have_frame) { + // Flush: the last packet(s) may not have produced a frame until the + // decoder is told there's no more input. + avcodec_send_packet(codec_ctx.get(), nullptr); + have_frame = avcodec_receive_frame(codec_ctx.get(), frame.get()) == 0; + } + if (!have_frame) { + throw ThumbnailException("THUMBNAIL_ERROR", "Failed to read a video frame."); + } + + return frame; +} + +// Computes the output size, honoring max_width/max_height (<= 0 keeps the +// source resolution), preserving aspect ratio. +void ScaledSize(int src_width, int src_height, int64_t max_width, int64_t max_height, int *out_width, + int *out_height) { + if (max_width <= 0 && max_height <= 0) { + *out_width = src_width; + *out_height = src_height; + return; + } + const double aspect = static_cast(src_width) / static_cast(src_height); + double width = max_width > 0 ? static_cast(max_width) : max_height * aspect; + double height = max_height > 0 ? static_cast(max_height) : max_width / aspect; + if (max_width > 0 && max_height > 0) { + if (width / aspect > height) { + width = height * aspect; + } else { + height = width / aspect; + } + } + *out_width = static_cast(width); + *out_height = static_cast(height); +} + +// Scales `src` into a newly-allocated frame in `dst_format` at +// `width`x`height`. +FramePtr ScaleFrame(const AVFrame *src, AVPixelFormat dst_format, int width, int height) { + SwsContextPtr sws(sws_getContext(src->width, src->height, static_cast(src->format), width, height, + dst_format, SWS_BILINEAR, nullptr, nullptr, nullptr)); + if (!sws) { + throw ThumbnailException("THUMBNAIL_ERROR", "Failed to scale the decoded frame."); + } + + FramePtr dst(av_frame_alloc()); + dst->width = width; + dst->height = height; + dst->format = dst_format; + dst->color_range = AVCOL_RANGE_JPEG; + if (av_frame_get_buffer(dst.get(), 0) < 0) { + throw ThumbnailException("THUMBNAIL_ERROR", "Failed to allocate the scaled frame."); + } + sws_scale(sws.get(), src->data, src->linesize, 0, src->height, dst->data, dst->linesize); + return dst; +} + +// Encodes an already-scaled `frame` via a libavcodec image encoder (mjpeg or +// png). `quality` (0-100) only applies to jpeg. +std::vector EncodeWithCodec(AVCodecID codec_id, const AVFrame *frame, int64_t quality) { + const AVCodec *encoder = avcodec_find_encoder(codec_id); + if (!encoder) { + throw ThumbnailException("THUMBNAIL_ERROR", "No encoder available for the requested format."); + } + + CodecContextPtr codec_ctx(avcodec_alloc_context3(encoder)); + codec_ctx->width = frame->width; + codec_ctx->height = frame->height; + codec_ctx->pix_fmt = static_cast(frame->format); + codec_ctx->time_base = AVRational{1, 1}; + codec_ctx->color_range = frame->color_range; + if (codec_id == AV_CODEC_ID_MJPEG) { + codec_ctx->flags |= AV_CODEC_FLAG_QSCALE; + // FFmpeg's qscale is inverted (lower is better); map 0-100 quality onto + // its ~2-31 usable range. + codec_ctx->global_quality = FF_QP2LAMBDA * (2 + (31 - 2) * (100 - quality) / 100); + } + if (avcodec_open2(codec_ctx.get(), encoder, nullptr) < 0) { + throw ThumbnailException("THUMBNAIL_ERROR", "Failed to open the image encoder."); + } + + PacketPtr packet(av_packet_alloc()); + if (avcodec_send_frame(codec_ctx.get(), frame) < 0) { + throw ThumbnailException("THUMBNAIL_ERROR", "Failed to encode the thumbnail image."); + } + avcodec_send_frame(codec_ctx.get(), nullptr); // Flush. + if (avcodec_receive_packet(codec_ctx.get(), packet.get()) < 0) { + throw ThumbnailException("THUMBNAIL_ERROR", "Failed to encode the thumbnail image."); + } + + return std::vector(packet->data, packet->data + packet->size); +} + +// Encodes an already-scaled RGB24 `frame` as WebP. +std::vector EncodeWebP(const AVFrame *frame, int64_t quality) { + uint8_t *output = nullptr; + size_t size; + if (quality >= 100) { + size = WebPEncodeLosslessRGB(frame->data[0], frame->width, frame->height, frame->linesize[0], &output); + } else { + size = WebPEncodeRGB(frame->data[0], frame->width, frame->height, frame->linesize[0], + static_cast(quality), &output); + } + if (size == 0 || output == nullptr) { + throw ThumbnailException("THUMBNAIL_ERROR", "Failed to encode the thumbnail as WebP."); + } + std::vector result(output, output + size); + WebPFree(output); + return result; +} + +std::vector GenerateThumbnailData(const std::string &video, const std::map *headers, + StreamThumbnailThumbnailFormat format, int64_t max_width, int64_t max_height, + int64_t time_ms, int64_t quality) { + const FramePtr native = DecodeFrame(VideoPath(video), headers, time_ms); + + int width, height; + ScaledSize(native->width, native->height, max_width, max_height, &width, &height); + + switch (format) { + case STREAM_THUMBNAIL_THUMBNAIL_FORMAT_JPEG: { + const FramePtr scaled = ScaleFrame(native.get(), AV_PIX_FMT_YUV420P, width, height); + return EncodeWithCodec(AV_CODEC_ID_MJPEG, scaled.get(), quality); + } + case STREAM_THUMBNAIL_THUMBNAIL_FORMAT_PNG: { + const FramePtr scaled = ScaleFrame(native.get(), AV_PIX_FMT_RGB24, width, height); + return EncodeWithCodec(AV_CODEC_ID_PNG, scaled.get(), quality); + } + case STREAM_THUMBNAIL_THUMBNAIL_FORMAT_WEBP: { + const FramePtr scaled = ScaleFrame(native.get(), AV_PIX_FMT_RGB24, width, height); + return EncodeWebP(scaled.get(), quality); + } + } + throw ThumbnailException("THUMBNAIL_ERROR", "Unknown image format."); +} + +std::string WriteThumbnailFile(const std::string &video, const std::map *headers, + const std::string *thumbnail_path, StreamThumbnailThumbnailFormat format, int64_t max_width, + int64_t max_height, int64_t time_ms, int64_t quality) { + const std::vector data = GenerateThumbnailData(video, headers, format, max_width, max_height, time_ms, quality); + const std::string ext = FileExtension(format); + const std::string video_path = VideoPath(video); + + std::string save_path = thumbnail_path != nullptr ? *thumbnail_path : std::string(); + if (save_path.empty() && !IsLocalPath(video)) { + save_path = g_get_tmp_dir(); + } + + const size_t dot = video_path.find_last_of('.'); + const std::string base = dot == std::string::npos ? video_path : video_path.substr(0, dot); + + std::string full_path; + if (!save_path.empty()) { + const bool ends_with_ext = + save_path.size() >= ext.size() && save_path.compare(save_path.size() - ext.size(), ext.size(), ext) == 0; + if (ends_with_ext) { + full_path = save_path; + } else { + const size_t slash = base.find_last_of('/'); + const std::string file_name = (slash == std::string::npos ? base : base.substr(slash + 1)) + "." + ext; + full_path = save_path.back() == '/' ? save_path + file_name : save_path + "/" + file_name; + } + } else { + full_path = base + "." + ext; + } + + GError *error = nullptr; + if (!g_file_set_contents(full_path.c_str(), reinterpret_cast(data.data()), data.size(), &error)) { + const std::string message = error != nullptr ? error->message : "Failed to write the thumbnail to disk."; + if (error != nullptr) g_error_free(error); + throw ThumbnailException("WRITE_ERROR", message); + } + + return full_path; +} + +// Extracts a std::map from a nullable FlValue map, or nullptr +// if `headers` is null. +std::unique_ptr> ExtractHeaders(FlValue *headers) { + if (headers == nullptr || fl_value_get_type(headers) != FL_VALUE_TYPE_MAP) return nullptr; + auto result = std::make_unique>(); + const size_t count = fl_value_get_length(headers); + for (size_t i = 0; i < count; i++) { + FlValue *key = fl_value_get_map_key(headers, i); + FlValue *value = fl_value_get_map_value(headers, i); + if (fl_value_get_type(key) == FL_VALUE_TYPE_STRING && fl_value_get_type(value) == FL_VALUE_TYPE_STRING) { + (*result)[fl_value_get_string(key)] = fl_value_get_string(value); + } + } + return result; +} + +// Task data + result plumbing shared by both async methods. A background +// GTask (run via g_task_run_in_thread) does the decode/encode work, then its +// completion callback — invoked back on the thread that owns this +// GMainContext, per GTask's documented contract — replies to Flutter. This +// avoids calling into the generated FlBasicMessageChannel-backed respond +// functions from a non-main thread. +struct ThumbnailDataTaskData { + std::string video; + std::unique_ptr> headers; + StreamThumbnailThumbnailFormat format; + int64_t max_width; + int64_t max_height; + int64_t time_ms; + int64_t quality; +}; + +struct ThumbnailDataResult { + bool ok = false; + std::vector bytes; + std::string error_code; + std::string error_message; +}; + +struct ThumbnailFileTaskData { + std::string video; + std::unique_ptr> headers; + std::string thumbnail_path; + bool has_thumbnail_path; + StreamThumbnailThumbnailFormat format; + int64_t max_width; + int64_t max_height; + int64_t time_ms; + int64_t quality; +}; + +struct ThumbnailFileResult { + bool ok = false; + std::string path; + std::string error_code; + std::string error_message; +}; + +void ThumbnailDataThread(GTask *task, gpointer, gpointer task_data, GCancellable *) { + auto *data = static_cast(task_data); + auto *result = new ThumbnailDataResult(); + try { + result->bytes = + GenerateThumbnailData(data->video, data->headers.get(), data->format, data->max_width, data->max_height, + data->time_ms, data->quality); + result->ok = true; + } catch (const ThumbnailException &e) { + result->error_code = e.code(); + result->error_message = e.what(); + } catch (const std::exception &e) { + // Anything unexpected (std::bad_alloc, ...) must still come back as a + // Flutter-side error: letting it escape into GLib's C frames would + // terminate the process. + result->error_code = "THUMBNAIL_ERROR"; + result->error_message = e.what(); + } + g_task_return_pointer(task, result, [](gpointer p) { delete static_cast(p); }); +} + +void OnThumbnailDataDone(GObject *, GAsyncResult *res, gpointer user_data) { + // Not g_autoptr: the generated header doesn't declare a + // G_DEFINE_AUTOPTR_CLEANUP_FUNC for StreamThumbnailStreamThumbnailHostApiResponseHandle. + StreamThumbnailStreamThumbnailHostApiResponseHandle *handle = STREAM_THUMBNAIL_STREAM_THUMBNAIL_HOST_API_RESPONSE_HANDLE(user_data); + std::unique_ptr result( + static_cast(g_task_propagate_pointer(G_TASK(res), nullptr))); + if (result->ok) { + stream_thumbnail_stream_thumbnail_host_api_respond_thumbnail_data(handle, result->bytes.data(), result->bytes.size()); + } else { + stream_thumbnail_stream_thumbnail_host_api_respond_error_thumbnail_data(handle, result->error_code.c_str(), + result->error_message.c_str(), nullptr); + } + g_object_unref(handle); +} + +void ThumbnailFileThread(GTask *task, gpointer, gpointer task_data, GCancellable *) { + auto *data = static_cast(task_data); + auto *result = new ThumbnailFileResult(); + try { + result->path = WriteThumbnailFile(data->video, data->headers.get(), + data->has_thumbnail_path ? &data->thumbnail_path : nullptr, data->format, + data->max_width, data->max_height, data->time_ms, data->quality); + result->ok = true; + } catch (const ThumbnailException &e) { + result->error_code = e.code(); + result->error_message = e.what(); + } catch (const std::exception &e) { + result->error_code = "THUMBNAIL_ERROR"; + result->error_message = e.what(); + } + g_task_return_pointer(task, result, [](gpointer p) { delete static_cast(p); }); +} + +void OnThumbnailFileDone(GObject *, GAsyncResult *res, gpointer user_data) { + StreamThumbnailStreamThumbnailHostApiResponseHandle *handle = STREAM_THUMBNAIL_STREAM_THUMBNAIL_HOST_API_RESPONSE_HANDLE(user_data); + std::unique_ptr result( + static_cast(g_task_propagate_pointer(G_TASK(res), nullptr))); + if (result->ok) { + stream_thumbnail_stream_thumbnail_host_api_respond_thumbnail_file(handle, result->path.c_str()); + } else { + stream_thumbnail_stream_thumbnail_host_api_respond_error_thumbnail_file(handle, result->error_code.c_str(), + result->error_message.c_str(), nullptr); + } + g_object_unref(handle); +} + +void HandleThumbnailData(StreamThumbnailThumbnailRequest *request, StreamThumbnailStreamThumbnailHostApiResponseHandle *response_handle, + gpointer) { + auto *data = new ThumbnailDataTaskData{ + stream_thumbnail_thumbnail_request_get_video(request), + ExtractHeaders(stream_thumbnail_thumbnail_request_get_headers(request)), + stream_thumbnail_thumbnail_request_get_format(request), + stream_thumbnail_thumbnail_request_get_max_width(request), + stream_thumbnail_thumbnail_request_get_max_height(request), + stream_thumbnail_thumbnail_request_get_time_ms(request), + stream_thumbnail_thumbnail_request_get_quality(request), + }; + + GTask *task = g_task_new(nullptr, nullptr, OnThumbnailDataDone, g_object_ref(response_handle)); + g_task_set_task_data(task, data, [](gpointer p) { delete static_cast(p); }); + g_task_run_in_thread(task, ThumbnailDataThread); + g_object_unref(task); +} + +void HandleThumbnailFile(StreamThumbnailThumbnailRequest *request, StreamThumbnailStreamThumbnailHostApiResponseHandle *response_handle, + gpointer) { + const gchar *thumbnail_path = stream_thumbnail_thumbnail_request_get_thumbnail_path(request); + auto *data = new ThumbnailFileTaskData{ + stream_thumbnail_thumbnail_request_get_video(request), + ExtractHeaders(stream_thumbnail_thumbnail_request_get_headers(request)), + thumbnail_path != nullptr ? std::string(thumbnail_path) : std::string(), + thumbnail_path != nullptr, + stream_thumbnail_thumbnail_request_get_format(request), + stream_thumbnail_thumbnail_request_get_max_width(request), + stream_thumbnail_thumbnail_request_get_max_height(request), + stream_thumbnail_thumbnail_request_get_time_ms(request), + stream_thumbnail_thumbnail_request_get_quality(request), + }; + + GTask *task = g_task_new(nullptr, nullptr, OnThumbnailFileDone, g_object_ref(response_handle)); + g_task_set_task_data(task, data, [](gpointer p) { delete static_cast(p); }); + g_task_run_in_thread(task, ThumbnailFileThread); + g_object_unref(task); +} + +constexpr StreamThumbnailStreamThumbnailHostApiVTable kVTable = { + HandleThumbnailData, + HandleThumbnailFile, +}; + +} // namespace + +static void stream_thumbnail_plugin_dispose(GObject *object) { + G_OBJECT_CLASS(stream_thumbnail_plugin_parent_class)->dispose(object); +} + +static void stream_thumbnail_plugin_class_init(StreamThumbnailPluginClass *klass) { + G_OBJECT_CLASS(klass)->dispose = stream_thumbnail_plugin_dispose; +} + +static void stream_thumbnail_plugin_init(StreamThumbnailPlugin *) {} + +void stream_thumbnail_plugin_register_with_registrar(FlPluginRegistrar *registrar) { + StreamThumbnailPlugin *plugin = + STREAM_THUMBNAIL_PLUGIN(g_object_new(stream_thumbnail_plugin_get_type(), nullptr)); + + stream_thumbnail_stream_thumbnail_host_api_set_method_handlers(fl_plugin_registrar_get_messenger(registrar), nullptr, &kVTable, + g_object_ref(plugin), g_object_unref); + + g_object_unref(plugin); +} diff --git a/packages/stream_thumbnail/macos/stream_thumbnail.podspec b/packages/stream_thumbnail/macos/stream_thumbnail.podspec new file mode 100644 index 00000000..e15070ca --- /dev/null +++ b/packages/stream_thumbnail/macos/stream_thumbnail.podspec @@ -0,0 +1,22 @@ +# +# 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.1.0' + 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/**/*.swift' + s.dependency 'FlutterMacOS' + s.dependency 'libwebp' + + s.platform = :osx, '10.15' + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } + s.swift_version = '5.9' +end diff --git a/packages/stream_thumbnail/macos/stream_thumbnail/Package.swift b/packages/stream_thumbnail/macos/stream_thumbnail/Package.swift new file mode 100644 index 00000000..ec2f814e --- /dev/null +++ b/packages/stream_thumbnail/macos/stream_thumbnail/Package.swift @@ -0,0 +1,27 @@ +// 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: [ + .macOS("10.15") + ], + 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") + ] + ) + ] +) diff --git a/packages/stream_thumbnail/macos/stream_thumbnail/Sources/stream_thumbnail/Messages.g.swift b/packages/stream_thumbnail/macos/stream_thumbnail/Sources/stream_thumbnail/Messages.g.swift new file mode 100644 index 00000000..8d6123b8 --- /dev/null +++ b/packages/stream_thumbnail/macos/stream_thumbnail/Sources/stream_thumbnail/Messages.g.swift @@ -0,0 +1,365 @@ +// Autogenerated from Pigeon (v27.3.0), do not edit directly. +// See also: https://pub.dev/packages/pigeon + +import Foundation + +#if os(iOS) + import Flutter +#elseif os(macOS) + import FlutterMacOS +#else + #error("Unsupported platform.") +#endif + +/// Error class for passing custom error details to Dart side. +final class PigeonError: Error { + let code: String + let message: String? + let details: Sendable? + + init(code: String, message: String?, details: Sendable?) { + self.code = code + self.message = message + self.details = details + } + + var localizedDescription: String { + return + "PigeonError(code: \(code), message: \(message ?? ""), details: \(details ?? "")" + } +} + +private func wrapResult(_ result: Any?) -> [Any?] { + return [result] +} + +private func wrapError(_ error: Any) -> [Any?] { + if let pigeonError = error as? PigeonError { + return [ + pigeonError.code, + pigeonError.message, + pigeonError.details, + ] + } + if let flutterError = error as? FlutterError { + return [ + flutterError.code, + flutterError.message, + flutterError.details, + ] + } + return [ + "\(error)", + "\(Swift.type(of: error))", + "Stacktrace: \(Thread.callStackSymbols)", + ] +} + +enum MessagesPigeonInternal { + static func isNullish(_ value: Any?) -> Bool { + guard let innerValue = value else { + return true + } + + if case Optional.some(Optional.none) = value { + return true + } + + return innerValue is NSNull + } + static func doubleEquals(_ lhs: Double, _ rhs: Double) -> Bool { + return (lhs.isNaN && rhs.isNaN) || lhs == rhs + } + + static func doubleHash(_ value: Double, _ hasher: inout Hasher) { + if value.isNaN { + hasher.combine(0x7FF8000000000000) + } else { + // Normalize -0.0 to 0.0 + hasher.combine(value == 0 ? 0 : value) + } + } + + static func deepEquals(_ lhs: Any?, _ rhs: Any?) -> Bool { + let cleanLhs = nilOrValue(lhs) as Any? + let cleanRhs = nilOrValue(rhs) as Any? + switch (cleanLhs, cleanRhs) { + case (nil, nil): + return true + + case (nil, _), (_, nil): + return false + + case (let lhs as AnyObject, let rhs as AnyObject) where lhs === rhs: + return true + + case is (Void, Void): + return true + + case (let lhsArray, let rhsArray) as ([Any?], [Any?]): + guard lhsArray.count == rhsArray.count else { return false } + for (index, element) in lhsArray.enumerated() { + if !deepEquals(element, rhsArray[index]) { + return false + } + } + return true + + case (let lhsArray, let rhsArray) as ([Double], [Double]): + guard lhsArray.count == rhsArray.count else { return false } + for (index, element) in lhsArray.enumerated() { + if !doubleEquals(element, rhsArray[index]) { + return false + } + } + return true + + case (let lhsDictionary, let rhsDictionary) as ([AnyHashable: Any?], [AnyHashable: Any?]): + guard lhsDictionary.count == rhsDictionary.count else { return false } + for (lhsKey, lhsValue) in lhsDictionary { + var found = false + for (rhsKey, rhsValue) in rhsDictionary { + if deepEquals(lhsKey, rhsKey) { + if deepEquals(lhsValue, rhsValue) { + found = true + break + } else { + return false + } + } + } + if !found { return false } + } + return true + + case (let lhs as Double, let rhs as Double): + return doubleEquals(lhs, rhs) + + case (let lhsHashable, let rhsHashable) as (AnyHashable, AnyHashable): + return lhsHashable == rhsHashable + + default: + return false + } + } + + static func deepHash(value: Any?, hasher: inout Hasher) { + let cleanValue = nilOrValue(value) as Any? + if let cleanValue = cleanValue { + if let doubleValue = cleanValue as? Double { + doubleHash(doubleValue, &hasher) + } else if let valueList = cleanValue as? [Any?] { + for item in valueList { + deepHash(value: item, hasher: &hasher) + } + } else if let valueList = cleanValue as? [Double] { + for item in valueList { + doubleHash(item, &hasher) + } + } else if let valueDict = cleanValue as? [AnyHashable: Any?] { + var result = 0 + for (key, value) in valueDict { + var entryKeyHasher = Hasher() + deepHash(value: key, hasher: &entryKeyHasher) + var entryValueHasher = Hasher() + deepHash(value: value, hasher: &entryValueHasher) + result = result &+ ((entryKeyHasher.finalize() &* 31) ^ entryValueHasher.finalize()) + } + hasher.combine(result) + } else if let hashableValue = cleanValue as? AnyHashable { + hasher.combine(hashableValue) + } else { + hasher.combine(String(describing: cleanValue)) + } + } else { + hasher.combine(0) + } + } + +} + +private func nilOrValue(_ value: Any?) -> T? { + if value is NSNull { return nil } + return value as! T? +} + + +/// Wire representation of the image format for a generated thumbnail. +enum ThumbnailFormat: Int, CaseIterable { + case jpeg = 0 + case png = 1 + case webp = 2 +} + +/// A single thumbnail generation request sent to the native platform. +/// +/// Generated class from Pigeon that represents data sent in messages. +struct ThumbnailRequest: Hashable, CustomStringConvertible { + var video: String + var headers: [String: String]? = nil + var thumbnailPath: String? = nil + var format: ThumbnailFormat + var maxHeight: Int64 + var maxWidth: Int64 + var timeMs: Int64 + var quality: Int64 + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> ThumbnailRequest? { + let video = pigeonVar_list[0] as! String + let headers: [String: String]? = nilOrValue(pigeonVar_list[1]) + let thumbnailPath: String? = nilOrValue(pigeonVar_list[2]) + let format = pigeonVar_list[3] as! ThumbnailFormat + let maxHeight = pigeonVar_list[4] as! Int64 + let maxWidth = pigeonVar_list[5] as! Int64 + let timeMs = pigeonVar_list[6] as! Int64 + let quality = pigeonVar_list[7] as! Int64 + + return ThumbnailRequest( + video: video, + headers: headers, + thumbnailPath: thumbnailPath, + format: format, + maxHeight: maxHeight, + maxWidth: maxWidth, + timeMs: timeMs, + quality: quality + ) + } + func toList() -> [Any?] { + return [ + video, + headers, + thumbnailPath, + format, + maxHeight, + maxWidth, + timeMs, + quality, + ] + } + static func == (lhs: ThumbnailRequest, rhs: ThumbnailRequest) -> Bool { + if Swift.type(of: lhs) != Swift.type(of: rhs) { + return false + } + return MessagesPigeonInternal.deepEquals(lhs.video, rhs.video) && MessagesPigeonInternal.deepEquals(lhs.headers, rhs.headers) && MessagesPigeonInternal.deepEquals(lhs.thumbnailPath, rhs.thumbnailPath) && MessagesPigeonInternal.deepEquals(lhs.format, rhs.format) && MessagesPigeonInternal.deepEquals(lhs.maxHeight, rhs.maxHeight) && MessagesPigeonInternal.deepEquals(lhs.maxWidth, rhs.maxWidth) && MessagesPigeonInternal.deepEquals(lhs.timeMs, rhs.timeMs) && MessagesPigeonInternal.deepEquals(lhs.quality, rhs.quality) + } + + func hash(into hasher: inout Hasher) { + hasher.combine("ThumbnailRequest") + MessagesPigeonInternal.deepHash(value: video, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: headers, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: thumbnailPath, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: format, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: maxHeight, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: maxWidth, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: timeMs, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: quality, hasher: &hasher) + } + + public var description: String { + return "ThumbnailRequest(video: \(String(describing: video)), headers: \(String(describing: headers)), thumbnailPath: \(String(describing: thumbnailPath)), format: \(String(describing: format)), maxHeight: \(String(describing: maxHeight)), maxWidth: \(String(describing: maxWidth)), timeMs: \(String(describing: timeMs)), quality: \(String(describing: quality)))" + } +} + +private class MessagesPigeonCodecReader: FlutterStandardReader { + override func readValue(ofType type: UInt8) -> Any? { + switch type { + case 129: + let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?) + if let enumResultAsInt = enumResultAsInt { + return ThumbnailFormat(rawValue: enumResultAsInt) + } + return nil + case 130: + return ThumbnailRequest.fromList(self.readValue() as! [Any?]) + default: + return super.readValue(ofType: type) + } + } +} + +private class MessagesPigeonCodecWriter: FlutterStandardWriter { + override func writeValue(_ value: Any) { + if let value = value as? ThumbnailFormat { + super.writeByte(129) + super.writeValue(value.rawValue) + } else if let value = value as? ThumbnailRequest { + super.writeByte(130) + super.writeValue(value.toList()) + } else { + super.writeValue(value) + } + } +} + +private class MessagesPigeonCodecReaderWriter: FlutterStandardReaderWriter { + override func reader(with data: Data) -> FlutterStandardReader { + return MessagesPigeonCodecReader(data: data) + } + + override func writer(with data: NSMutableData) -> FlutterStandardWriter { + return MessagesPigeonCodecWriter(data: data) + } +} + +class MessagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable { + static let shared = MessagesPigeonCodec(readerWriter: MessagesPigeonCodecReaderWriter()) +} + + +/// Generated protocol from Pigeon that represents a handler of messages from Flutter. +protocol StreamThumbnailHostApi { + /// Generates a thumbnail for [ThumbnailRequest.video] and returns its bytes. + func thumbnailData(request: ThumbnailRequest, completion: @escaping (Result) -> Void) + /// Generates a thumbnail for [ThumbnailRequest.video] and returns the path + /// it was written to. + func thumbnailFile(request: ThumbnailRequest, completion: @escaping (Result) -> Void) +} + +/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. +class StreamThumbnailHostApiSetup { + static var codec: FlutterStandardMessageCodec { MessagesPigeonCodec.shared } + /// Sets up an instance of `StreamThumbnailHostApi` to handle messages through the `binaryMessenger`. + static func setUp(binaryMessenger: FlutterBinaryMessenger, api: StreamThumbnailHostApi?, messageChannelSuffix: String = "") { + let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" + /// Generates a thumbnail for [ThumbnailRequest.video] and returns its bytes. + let thumbnailDataChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.stream_thumbnail.StreamThumbnailHostApi.thumbnailData\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + thumbnailDataChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let requestArg = args[0] as! ThumbnailRequest + api.thumbnailData(request: requestArg) { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + thumbnailDataChannel.setMessageHandler(nil) + } + /// Generates a thumbnail for [ThumbnailRequest.video] and returns the path + /// it was written to. + let thumbnailFileChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.stream_thumbnail.StreamThumbnailHostApi.thumbnailFile\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + thumbnailFileChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let requestArg = args[0] as! ThumbnailRequest + api.thumbnailFile(request: requestArg) { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + thumbnailFileChannel.setMessageHandler(nil) + } + } +} diff --git a/packages/stream_thumbnail/macos/stream_thumbnail/Sources/stream_thumbnail/StreamThumbnailPlugin.swift b/packages/stream_thumbnail/macos/stream_thumbnail/Sources/stream_thumbnail/StreamThumbnailPlugin.swift new file mode 100644 index 00000000..0f4f5469 --- /dev/null +++ b/packages/stream_thumbnail/macos/stream_thumbnail/Sources/stream_thumbnail/StreamThumbnailPlugin.swift @@ -0,0 +1,213 @@ +import AVFoundation +import Cocoa +import FlutterMacOS +import libwebp + +/// A generation failure that couldn't produce a frame/encode, or a failure +/// writing the encoded thumbnail to disk. +private enum ThumbnailError: Error { + case generationFailed + case writeFailed(NSError) +} + +public class StreamThumbnailPlugin: NSObject, FlutterPlugin, StreamThumbnailHostApi { + public static func register(with registrar: FlutterPluginRegistrar) { + let instance = StreamThumbnailPlugin() + StreamThumbnailHostApiSetup.setUp(binaryMessenger: registrar.messenger, api: instance) + } + + func thumbnailData( + request: ThumbnailRequest, completion: @escaping (Result) -> Void + ) { + DispatchQueue.global(qos: .userInitiated).async { + do { + let data = try Self.generateThumbnailData(request: request) + let result = FlutterStandardTypedData(bytes: data) + DispatchQueue.main.async { completion(.success(result)) } + } catch { + DispatchQueue.main.async { completion(.failure(Self.pigeonError(for: error))) } + } + } + } + + func thumbnailFile( + request: ThumbnailRequest, completion: @escaping (Result) -> Void + ) { + DispatchQueue.global(qos: .userInitiated).async { + do { + let path = try Self.writeThumbnailFile(request: request) + DispatchQueue.main.async { completion(.success(path)) } + } catch { + DispatchQueue.main.async { completion(.failure(Self.pigeonError(for: error))) } + } + } + } + + private static func pigeonError(for error: Error) -> PigeonError { + switch error { + case ThumbnailError.generationFailed: + return PigeonError( + code: "THUMBNAIL_ERROR", message: "Failed to generate a thumbnail for the video.", details: nil) + case ThumbnailError.writeFailed(let nsError): + return PigeonError(code: "Error \(nsError.code)", message: nsError.domain, details: nsError.localizedDescription) + default: + return PigeonError(code: "THUMBNAIL_ERROR", message: (error as NSError).localizedDescription, details: nil) + } + } + + private static func videoURL(for video: String) throws -> URL { + if video.hasPrefix("file://") { + return URL(fileURLWithPath: String(video.dropFirst(7))) + } else if video.hasPrefix("/") { + return URL(fileURLWithPath: video) + } else if let url = URL(string: video) { + return url + } + throw ThumbnailError.generationFailed + } + + private static func writeThumbnailFile(request: ThumbnailRequest) throws -> String { + let data = try generateThumbnailData(request: request) + let videoURL = try self.videoURL(for: request.video) + let isLocalFile = request.video.hasPrefix("/") || request.video.hasPrefix("file://") + let ext = fileExtension(for: request.format) + + var savePath = request.thumbnailPath + if savePath == nil && !isLocalFile { + savePath = NSSearchPathForDirectoriesInDomains(.cachesDirectory, .userDomainMask, true).last + } + + var thumbnailURL = videoURL.deletingPathExtension().appendingPathExtension(ext) + if let savePath, !savePath.isEmpty { + let lastPart = thumbnailURL.lastPathComponent + thumbnailURL = URL(fileURLWithPath: savePath) + if thumbnailURL.pathExtension != ext { + thumbnailURL = thumbnailURL.appendingPathComponent(lastPart) + } + } + + do { + try data.write(to: thumbnailURL, options: .atomic) + } catch { + throw ThumbnailError.writeFailed(error as NSError) + } + + return thumbnailURL.path + } + + private static func generateThumbnailData(request: ThumbnailRequest) throws -> Data { + let url = try videoURL(for: request.video) + + var options: [String: Any]? + if let headers = request.headers, !headers.isEmpty { + options = ["AVURLAssetHTTPHeaderFieldsKey": headers] + } + + let asset = AVURLAsset(url: url, options: options) + let generator = AVAssetImageGenerator(asset: asset) + generator.appliesPreferredTrackTransform = true + generator.maximumSize = CGSize(width: CGFloat(request.maxWidth), height: CGFloat(request.maxHeight)) + generator.requestedTimeToleranceBefore = .zero + generator.requestedTimeToleranceAfter = CMTime(value: 100, timescale: 1000) + + guard + let cgImage = try? generator.copyCGImage( + at: CMTime(value: request.timeMs, timescale: 1000), actualTime: nil) + else { + throw ThumbnailError.generationFailed + } + + switch request.format { + case .jpeg: + let quality = Double(request.quality) / 100.0 + guard + let data = NSBitmapImageRep(cgImage: cgImage).representation( + using: .jpeg, properties: [.compressionFactor: quality]) + else { + throw ThumbnailError.generationFailed + } + return data + case .png: + guard let data = NSBitmapImageRep(cgImage: cgImage).representation(using: .png, properties: [:]) else { + throw ThumbnailError.generationFailed + } + return data + case .webp: + return try encodeWebP(cgImage: cgImage, quality: Int(request.quality)) + } + } + + private static func encodeWebP(cgImage: CGImage, quality: Int) throws -> Data { + guard cgImage.colorSpace?.model == .rgb else { + throw ThumbnailError.generationFailed + } + + let alphaInfo = cgImage.alphaInfo + guard alphaInfo == .premultipliedFirst || alphaInfo == .noneSkipFirst else { + throw ThumbnailError.generationFailed + } + guard let cfData = cgImage.dataProvider?.data else { + throw ThumbnailError.generationFailed + } + + let width = Int32(cgImage.width) + let height = Int32(cgImage.height) + let stride = Int32(cgImage.bytesPerRow) + let byteOrder = cgImage.bitmapInfo.intersection(.byteOrderMask) + + var bytes = [UInt8](repeating: 0, count: CFDataGetLength(cfData)) + bytes.withUnsafeMutableBufferPointer { buffer in + CFDataGetBytes(cfData, CFRange(location: 0, length: buffer.count), buffer.baseAddress) + } + + var output: UnsafeMutablePointer? + var size = 0 + + switch byteOrder { + case .byteOrder32Little: + // Little-endian (Apple Silicon and Intel Macs are both little-endian). + bytes.withUnsafeMutableBufferPointer { buffer in + if quality == 100 { + size = WebPEncodeLosslessBGRA(buffer.baseAddress, width, height, stride, &output) + } else { + size = WebPEncodeBGRA(buffer.baseAddress, width, height, stride, Float(quality), &output) + } + } + case .byteOrder32Big: + bytes.withUnsafeMutableBufferPointer { buffer in + let base = buffer.baseAddress! + for y in 0..> 8) & 0x00FF_FFFF) + } + } + } + if quality == 100 { + size = WebPEncodeLosslessRGBA(base, width, height, stride, &output) + } else { + size = WebPEncodeRGBA(base, width, height, stride, Float(quality), &output) + } + } + default: + throw ThumbnailError.generationFailed + } + + guard size > 0, let output else { + throw ThumbnailError.generationFailed + } + + let data = Data(bytes: output, count: size) + WebPFree(output) + return data + } + + private static func fileExtension(for format: ThumbnailFormat) -> String { + switch format { + case .jpeg: return "jpg" + case .png: return "png" + case .webp: return "webp" + } + } +} diff --git a/packages/stream_thumbnail/pigeons/messages.dart b/packages/stream_thumbnail/pigeons/messages.dart new file mode 100644 index 00000000..c2b8469c --- /dev/null +++ b/packages/stream_thumbnail/pigeons/messages.dart @@ -0,0 +1,58 @@ +import 'package:pigeon/pigeon.dart'; + +// `swiftOut` is intentionally omitted here: iOS and macOS each need their own +// physical copy under their own SwiftPM package root (SPM rejects a target +// `path:` that escapes its package root), so it's passed explicitly via +// `--swift_out` for each platform in melos.yaml's `generate:pigeon` script +// instead of being fixed to a single path here. +@ConfigurePigeon( + PigeonOptions( + dartOut: 'lib/src/messages.g.dart', + kotlinOut: 'android/src/main/kotlin/io/getstream/stream_thumbnail/Messages.g.kt', + kotlinOptions: KotlinOptions(package: 'io.getstream.stream_thumbnail'), + cppHeaderOut: 'windows/pigeon/messages.g.h', + cppSourceOut: 'windows/pigeon/messages.g.cpp', + cppOptions: CppOptions(namespace: 'stream_thumbnail_windows', headerIncludePath: 'messages.g.h'), + gobjectHeaderOut: 'linux/pigeon/messages.g.h', + gobjectSourceOut: 'linux/pigeon/messages.g.cc', + gobjectOptions: GObjectOptions(module: 'StreamThumbnail', headerIncludePath: 'messages.g.h'), + dartPackageName: 'stream_thumbnail', + ), +) +/// Wire representation of the image format for a generated thumbnail. +enum ThumbnailFormat { jpeg, png, webp } + +/// A single thumbnail generation request sent to the native platform. +class ThumbnailRequest { + ThumbnailRequest({ + required this.video, + required this.headers, + required this.thumbnailPath, + required this.format, + required this.maxHeight, + required this.maxWidth, + required this.timeMs, + required this.quality, + }); + + final String video; + final Map? headers; + final String? thumbnailPath; + final ThumbnailFormat format; + final int maxHeight; + final int maxWidth; + final int timeMs; + final int quality; +} + +@HostApi() +abstract class StreamThumbnailHostApi { + /// Generates a thumbnail for [ThumbnailRequest.video] and returns its bytes. + @async + Uint8List thumbnailData(ThumbnailRequest request); + + /// Generates a thumbnail for [ThumbnailRequest.video] and returns the path + /// it was written to. + @async + String thumbnailFile(ThumbnailRequest request); +} diff --git a/packages/stream_thumbnail/pubspec.yaml b/packages/stream_thumbnail/pubspec.yaml index 2cd583de..061e67c3 100644 --- a/packages/stream_thumbnail/pubspec.yaml +++ b/packages/stream_thumbnail/pubspec.yaml @@ -20,6 +20,8 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter + mocktail: ^1.0.4 + pigeon: ^27.3.0 flutter: plugin: @@ -30,6 +32,12 @@ flutter: pluginClass: StreamThumbnailPlugin ios: pluginClass: StreamThumbnailPlugin + linux: + pluginClass: StreamThumbnailPlugin + macos: + pluginClass: StreamThumbnailPlugin web: pluginClass: StreamThumbnailWeb fileName: stream_thumbnail_web.dart + windows: + pluginClass: StreamThumbnailPluginCApi diff --git a/packages/stream_thumbnail/test/stream_thumbnail_test.dart b/packages/stream_thumbnail/test/stream_thumbnail_test.dart index 7af3bb7d..6e6ea6b2 100644 --- a/packages/stream_thumbnail/test/stream_thumbnail_test.dart +++ b/packages/stream_thumbnail/test/stream_thumbnail_test.dart @@ -1,7 +1,11 @@ +import 'dart:async'; + 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:mocktail/mocktail.dart'; +import 'package:stream_thumbnail/src/messages.g.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'; @@ -97,22 +101,24 @@ FakeStreamThumbnailPlatform useFakePlatform() { return fake; } +/// A mock of the Pigeon-generated host API, used to test +/// [MethodChannelStreamThumbnail] without a real platform channel. +class MockStreamThumbnailHostApi extends Mock implements StreamThumbnailHostApi {} + 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); - }); + setUpAll(() { + registerFallbackValue( + ThumbnailRequest( + video: '', + format: ThumbnailFormat.png, + maxHeight: 0, + maxWidth: 0, + timeMs: 0, + quality: 0, + ), + ); }); group('StreamThumbnail', () { @@ -158,6 +164,54 @@ void main() { expect(fake.filesCalled, isFalse); }); + test('thumbnailFiles rejects a thumbnailPath that names a file for multiple videos', () { + final fake = useFakePlatform(); + + expect( + () => StreamThumbnail.thumbnailFiles( + videos: ['a.mp4', 'b.mp4'], + thumbnailPath: '/tmp/thumb.png', + ), + throwsA(isA().having((e) => e.name, 'name', 'thumbnailPath')), + ); + expect(fake.filesCalled, isFalse); + }); + + test('thumbnailFiles matches the extension of the requested format, not just png', () { + useFakePlatform(); + + expect( + () => StreamThumbnail.thumbnailFiles( + videos: ['a.mp4', 'b.mp4'], + thumbnailPath: '/tmp/thumb.jpg', + imageFormat: StreamThumbnailFormat.jpeg, + ), + throwsA(isA()), + ); + }); + + test('thumbnailFiles allows a directory thumbnailPath for multiple videos', () async { + final fake = useFakePlatform(); + + await StreamThumbnail.thumbnailFiles( + videos: ['a.mp4', 'b.mp4'], + thumbnailPath: '/tmp/thumbs/', + ); + + expect(fake.filesCalled, isTrue); + }); + + test('thumbnailFiles allows a file thumbnailPath for a single video', () async { + final fake = useFakePlatform(); + + await StreamThumbnail.thumbnailFiles( + videos: ['a.mp4'], + thumbnailPath: '/tmp/thumb.png', + ); + + expect(fake.filesCalled, isTrue); + }); + test('thumbnailData rejects an empty video path', () { useFakePlatform(); @@ -169,26 +223,19 @@ void main() { }); 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; - } + late MockStreamThumbnailHostApi hostApi; + late MethodChannelStreamThumbnail channel; - test('thumbnailData invokes "data" with the encoded request and returns the bytes', () async { + setUp(() { + hostApi = MockStreamThumbnailHostApi(); + channel = MethodChannelStreamThumbnail(hostApi: hostApi); + }); + + test('thumbnailData sends the encoded request and returns the bytes', () async { final data = Uint8List.fromList([9, 8, 7]); - final calls = mockChannel(data); + when(() => hostApi.thumbnailData(any())).thenAnswer((_) async => data); - final result = await MethodChannelStreamThumbnail().thumbnailData( + final result = await channel.thumbnailData( video: 'a.mp4', headers: null, imageFormat: StreamThumbnailFormat.webp, @@ -199,22 +246,19 @@ void main() { ); 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); + final request = verify(() => hostApi.thumbnailData(captureAny())).captured.single as ThumbnailRequest; + expect(request.video, 'a.mp4'); + expect(request.format, ThumbnailFormat.webp); + expect(request.maxHeight, 10); + expect(request.maxWidth, 20); + expect(request.timeMs, 500); + expect(request.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'); + test('thumbnailFile wraps the returned path in an XFile', () async { + when(() => hostApi.thumbnailFile(any())).thenAnswer((_) async => '/tmp/thumb.png'); - final result = await MethodChannelStreamThumbnail().thumbnailFile( + final result = await channel.thumbnailFile( video: 'a.mp4', headers: null, thumbnailPath: null, @@ -227,12 +271,109 @@ void main() { expect(result.path, '/tmp/thumb.png'); }); + test('thumbnailData rethrows a PlatformException from the native side', () async { + when(() => hostApi.thumbnailData(any())).thenAnswer( + (_) async => throw PlatformException(code: 'THUMBNAIL_ERROR', message: 'native error'), + ); + + await expectLater( + channel.thumbnailData( + video: 'a.mp4', + headers: null, + imageFormat: StreamThumbnailFormat.png, + maxHeight: 0, + maxWidth: 0, + quality: 10, + ), + throwsA(isA().having((e) => e.code, 'code', 'THUMBNAIL_ERROR')), + ); + }); + + test('thumbnailFiles invokes the host API once per video and returns their XFiles in order', () async { + final paths = ['/a.png', '/b.png', '/c.png']; + var index = 0; + when(() => hostApi.thumbnailFile(any())).thenAnswer((_) async => paths[index++]); + + final result = await channel.thumbnailFiles( + videos: ['a.mp4', 'b.mp4', 'c.mp4'], + headers: null, + thumbnailPath: null, + imageFormat: StreamThumbnailFormat.png, + maxHeight: 0, + maxWidth: 0, + quality: 10, + ); + + verify(() => hostApi.thumbnailFile(any())).called(3); + expect(result.map((file) => file.path), paths); + }); + + test('thumbnailFiles stops at the first failing video instead of skipping it', () async { + var callCount = 0; + when(() => hostApi.thumbnailFile(any())).thenAnswer((_) async { + callCount++; + if (callCount == 2) { + throw PlatformException(code: 'THUMBNAIL_ERROR', message: 'boom'); + } + return '/ok.png'; + }); + + await expectLater( + channel.thumbnailFiles( + videos: ['a.mp4', 'b.mp4', 'c.mp4'], + headers: null, + thumbnailPath: null, + imageFormat: StreamThumbnailFormat.png, + maxHeight: 0, + maxWidth: 0, + quality: 10, + ), + throwsA(isA()), + ); + expect(callCount, 2); + }); + + test('thumbnailFiles decodes one video at a time', () async { + final pending = >[]; + when(() => hostApi.thumbnailFile(any())).thenAnswer((_) { + final completer = Completer(); + pending.add(completer); + return completer.future; + }); + + final result = channel.thumbnailFiles( + videos: ['a.mp4', 'b.mp4', 'c.mp4'], + headers: null, + thumbnailPath: null, + imageFormat: StreamThumbnailFormat.png, + maxHeight: 0, + maxWidth: 0, + quality: 10, + ); + + // Only the first request is in flight: the batch must not fan out and + // leave the platform juggling every decode at once. + await pumpEventQueue(); + expect(pending, hasLength(1)); + + pending[0].complete('/a.png'); + await pumpEventQueue(); + expect(pending, hasLength(2)); + + pending[1].complete('/b.png'); + await pumpEventQueue(); + expect(pending, hasLength(3)); + + pending[2].complete('/c.png'); + expect((await result).map((file) => file.path), ['/a.png', '/b.png', '/c.png']); + }); + test('a null timeMs is sent as -1 on Android', () async { debugDefaultTargetPlatformOverride = TargetPlatform.android; addTearDown(() => debugDefaultTargetPlatformOverride = null); - final calls = mockChannel(Uint8List(0)); + when(() => hostApi.thumbnailData(any())).thenAnswer((_) async => Uint8List(0)); - await MethodChannelStreamThumbnail().thumbnailData( + await channel.thumbnailData( video: 'a.mp4', headers: null, imageFormat: StreamThumbnailFormat.png, @@ -241,15 +382,16 @@ void main() { quality: 10, ); - expect((calls.single.arguments as Map)['timeMs'], -1); + final request = verify(() => hostApi.thumbnailData(captureAny())).captured.single as ThumbnailRequest; + expect(request.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)); + when(() => hostApi.thumbnailData(any())).thenAnswer((_) async => Uint8List(0)); - await MethodChannelStreamThumbnail().thumbnailData( + await channel.thumbnailData( video: 'a.mp4', headers: null, imageFormat: StreamThumbnailFormat.png, @@ -258,7 +400,8 @@ void main() { quality: 10, ); - expect((calls.single.arguments as Map)['timeMs'], 0); + final request = verify(() => hostApi.thumbnailData(captureAny())).captured.single as ThumbnailRequest; + expect(request.timeMs, 0); }); }); } diff --git a/packages/stream_thumbnail/windows/.gitignore b/packages/stream_thumbnail/windows/.gitignore new file mode 100644 index 00000000..b3eb2be1 --- /dev/null +++ b/packages/stream_thumbnail/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/packages/stream_thumbnail/windows/CMakeLists.txt b/packages/stream_thumbnail/windows/CMakeLists.txt new file mode 100644 index 00000000..63b97335 --- /dev/null +++ b/packages/stream_thumbnail/windows/CMakeLists.txt @@ -0,0 +1,73 @@ +# The Flutter tooling requires that developers have a version of Visual Studio +# installed that includes CMake 3.14 or later. You should not increase this +# version, as doing so will cause the plugin to fail to compile for some +# customers of the plugin. +cmake_minimum_required(VERSION 3.14) + +# Project-level configuration. +set(PROJECT_NAME "stream_thumbnail") +project(${PROJECT_NAME} LANGUAGES CXX) + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# This value is used when generating builds using this plugin, so it must +# not be changed +set(PLUGIN_NAME "stream_thumbnail_plugin") + +# Any new source files that you add to the plugin should be added here. +list(APPEND PLUGIN_SOURCES + "stream_thumbnail_plugin.cpp" + "stream_thumbnail_plugin.h" + "pigeon/messages.g.cpp" + "pigeon/messages.g.h" +) + +# Define the plugin library target. Its name must not be changed (see comment +# on PLUGIN_NAME above). +add_library(${PLUGIN_NAME} SHARED + "include/stream_thumbnail/stream_thumbnail_plugin_c_api.h" + "stream_thumbnail_plugin_c_api.cpp" + ${PLUGIN_SOURCES} +) + +# Apply a standard set of build settings that are configured in the +# application-level CMakeLists.txt. This can be removed for plugins that want +# full control over build settings. +apply_standard_settings(${PLUGIN_NAME}) + +# Symbols are hidden by default to reduce the chance of accidental conflicts +# between plugins. This should not be removed; any symbols that should be +# exported should be explicitly exported with the FLUTTER_PLUGIN_EXPORT macro. +set_target_properties(${PLUGIN_NAME} PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL) + +# Source include directories and library dependencies. Add any plugin-specific +# dependencies here. +target_include_directories(${PLUGIN_NAME} INTERFACE + "${CMAKE_CURRENT_SOURCE_DIR}/include") +target_link_libraries(${PLUGIN_NAME} PRIVATE flutter flutter_wrapper_plugin) + +# Media Foundation (video decode) and WIC (JPEG/PNG encode) are both part of +# the Windows SDK — no external dependency needed. WebP is not yet supported +# on Windows (see stream_thumbnail_plugin.cpp), so libwebp isn't linked here. +target_link_libraries(${PLUGIN_NAME} PRIVATE + mf.lib + mfplat.lib + mfreadwrite.lib + mfuuid.lib + strmiids.lib + windowscodecs.lib + ole32.lib + Shlwapi.lib +) + +# List of absolute paths to libraries that should be bundled with the plugin. +# This list could contain prebuilt libraries, or libraries created by an +# external build triggered from this build file. +set(stream_thumbnail_bundled_libraries + "" + PARENT_SCOPE +) diff --git a/packages/stream_thumbnail/windows/include/stream_thumbnail/stream_thumbnail_plugin_c_api.h b/packages/stream_thumbnail/windows/include/stream_thumbnail/stream_thumbnail_plugin_c_api.h new file mode 100644 index 00000000..37f5ab03 --- /dev/null +++ b/packages/stream_thumbnail/windows/include/stream_thumbnail/stream_thumbnail_plugin_c_api.h @@ -0,0 +1,23 @@ +#ifndef FLUTTER_PLUGIN_STREAM_THUMBNAIL_PLUGIN_C_API_H_ +#define FLUTTER_PLUGIN_STREAM_THUMBNAIL_PLUGIN_C_API_H_ + +#include + +#ifdef FLUTTER_PLUGIN_IMPL +#define FLUTTER_PLUGIN_EXPORT __declspec(dllexport) +#else +#define FLUTTER_PLUGIN_EXPORT __declspec(dllimport) +#endif + +#if defined(__cplusplus) +extern "C" { +#endif + +FLUTTER_PLUGIN_EXPORT void StreamThumbnailPluginCApiRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar); + +#if defined(__cplusplus) +} // extern "C" +#endif + +#endif // FLUTTER_PLUGIN_STREAM_THUMBNAIL_PLUGIN_C_API_H_ diff --git a/packages/stream_thumbnail/windows/pigeon/messages.g.cpp b/packages/stream_thumbnail/windows/pigeon/messages.g.cpp new file mode 100644 index 00000000..1a12dec4 --- /dev/null +++ b/packages/stream_thumbnail/windows/pigeon/messages.g.cpp @@ -0,0 +1,659 @@ +// Autogenerated from Pigeon (v27.3.0), do not edit directly. +// See also: https://pub.dev/packages/pigeon + +#undef _HAS_EXCEPTIONS + +#include "messages.g.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace stream_thumbnail_windows { +using ::flutter::BasicMessageChannel; +using ::flutter::CustomEncodableValue; +using ::flutter::EncodableList; +using ::flutter::EncodableMap; +using ::flutter::EncodableValue; + +FlutterError CreateConnectionError(const std::string channel_name) { + return FlutterError( + "channel-error", + "Unable to establish connection on channel: '" + channel_name + "'.", + EncodableValue("")); +} + +namespace { +template +bool PigeonInternalDeepEquals(const T& a, const T& b); + +bool PigeonInternalDeepEquals(const double& a, const double& b); + +template +bool PigeonInternalDeepEquals(const std::vector& a, const std::vector& b); + +template +bool PigeonInternalDeepEquals(const std::map& a, const std::map& b); + +template +bool PigeonInternalDeepEquals(const std::optional& a, const std::optional& b); + +template +bool PigeonInternalDeepEquals(const std::unique_ptr& a, const std::unique_ptr& b); + +bool PigeonInternalDeepEquals(const ::flutter::EncodableValue& a, const ::flutter::EncodableValue& b); + +template +bool PigeonInternalDeepEquals(const T& a, const T& b) { + return a == b; +} + +template +bool PigeonInternalDeepEquals(const std::vector& a, const std::vector& b) { + if (a.size() != b.size()) { + return false; + } + for (size_t i = 0; i < a.size(); ++i) { + if (!PigeonInternalDeepEquals(a[i], b[i])) { + return false; + } + } + return true; +} + +template +bool PigeonInternalDeepEquals(const std::map& a, const std::map& b) { + if (a.size() != b.size()) { + return false; + } + for (const auto& kv : a) { + bool found = false; + for (const auto& b_kv : b) { + if (PigeonInternalDeepEquals(kv.first, b_kv.first)) { + if (PigeonInternalDeepEquals(kv.second, b_kv.second)) { + found = true; + break; + } else { + return false; + } + } + } + if (!found) { + return false; + } + } + return true; +} + +bool PigeonInternalDeepEquals(const double& a, const double& b) { + // Normalize -0.0 to 0.0 and handle NaN equality. + return (a == b) || (std::isnan(a) && std::isnan(b)); +} + +template +bool PigeonInternalDeepEquals(const std::optional& a, const std::optional& b) { + if (!a && !b) { + return true; + } + if (!a || !b) { + return false; + } + return PigeonInternalDeepEquals(*a, *b); +} + +template +bool PigeonInternalDeepEquals(const std::unique_ptr& a, const std::unique_ptr& b) { + if (a.get() == b.get()) { + return true; + } + if (!a || !b) { + return false; + } + return PigeonInternalDeepEquals(*a, *b); +} + +bool PigeonInternalDeepEquals(const ::flutter::EncodableValue& a, const ::flutter::EncodableValue& b) { + if (a.index() != b.index()) { + return false; + } + if (const double* da = std::get_if(&a)) { + return PigeonInternalDeepEquals(*da, std::get(b)); + } else if (const ::flutter::EncodableList* la = std::get_if<::flutter::EncodableList>(&a)) { + return PigeonInternalDeepEquals(*la, std::get<::flutter::EncodableList>(b)); + } else if (const ::flutter::EncodableMap* ma = std::get_if<::flutter::EncodableMap>(&a)) { + return PigeonInternalDeepEquals(*ma, std::get<::flutter::EncodableMap>(b)); + } + return a == b; +} + +template +size_t PigeonInternalDeepHash(const T& v); + +size_t PigeonInternalDeepHash(const double& v); + +template +size_t PigeonInternalDeepHash(const std::vector& v); + +template +size_t PigeonInternalDeepHash(const std::map& v); + +template +size_t PigeonInternalDeepHash(const std::optional& v); + +template +size_t PigeonInternalDeepHash(const std::unique_ptr& v); + +size_t PigeonInternalDeepHash(const ::flutter::EncodableValue& v); + +template +size_t PigeonInternalDeepHash(const T& v) { + return std::hash()(v); +} + +template +size_t PigeonInternalDeepHash(const std::vector& v) { + size_t result = 1; + for (const auto& item : v) { + result = result * 31 + PigeonInternalDeepHash(item); + } + return result; +} + +template +size_t PigeonInternalDeepHash(const std::map& v) { + size_t result = 0; + for (const auto& kv : v) { + result += ((PigeonInternalDeepHash(kv.first) * 31) ^ PigeonInternalDeepHash(kv.second)); + } + return result; +} + +size_t PigeonInternalDeepHash(const double& v) { + if (std::isnan(v)) { + // Normalize NaN to a consistent hash. + return std::hash()(std::numeric_limits::quiet_NaN()); + } + if (v == 0.0) { + // Normalize -0.0 to 0.0 so they have the same hash code. + return std::hash()(0.0); + } + return std::hash()(v); +} + +template +size_t PigeonInternalDeepHash(const std::optional& v) { + return v ? PigeonInternalDeepHash(*v) : 0; +} + +template +size_t PigeonInternalDeepHash(const std::unique_ptr& v) { + return v ? PigeonInternalDeepHash(*v) : 0; +} + +size_t PigeonInternalDeepHash(const ::flutter::EncodableValue& v) { + size_t result = v.index(); + if (const double* dv = std::get_if(&v)) { + result = result * 31 + PigeonInternalDeepHash(*dv); + } else if (const ::flutter::EncodableList* lv = + std::get_if<::flutter::EncodableList>(&v)) { + result = result * 31 + PigeonInternalDeepHash(*lv); + } else if (const ::flutter::EncodableMap* mv = + std::get_if<::flutter::EncodableMap>(&v)) { + result = result * 31 + PigeonInternalDeepHash(*mv); + } else { + std::visit( + [&result](const auto& val) { + using T = std::decay_t; + if constexpr (!std::is_same_v && + !std::is_same_v && + !std::is_same_v && + !std::is_same_v && + !std::is_same_v) { + result = result * 31 + PigeonInternalDeepHash(val); + } + }, + v); + } + return result; +} + +template +std::string PigeonInternalToString(const T& v); + +std::string PigeonInternalToString(const bool& v); + +template +std::string PigeonInternalToString(const std::vector& v); + +template +std::string PigeonInternalToString(const std::map& v); + +template +std::string PigeonInternalToString(const std::optional& v); + +template +std::string PigeonInternalToString(const std::unique_ptr& v); + +std::string PigeonInternalToString(const ::flutter::EncodableValue& v); + +template +std::string PigeonInternalToString(const T& v) { + std::stringstream ss; + if constexpr (std::is_enum_v) { + ss << static_cast(v); + } else { + ss << v; + } + return ss.str(); +} + +std::string PigeonInternalToString(const bool& v) { + return v ? "true" : "false"; +} + +template +std::string PigeonInternalToString(const std::vector& v) { + std::stringstream ss; + ss << "["; + for (size_t i = 0; i < v.size(); ++i) { + if (i > 0) { + ss << ", "; + } + ss << PigeonInternalToString(v[i]); + } + ss << "]"; + return ss.str(); +} + +template +std::string PigeonInternalToString(const std::map& v) { + std::stringstream ss; + ss << "{"; + bool first = true; + for (const auto& kv : v) { + if (!first) { + ss << ", "; + } + first = false; + ss << PigeonInternalToString(kv.first) << ": " << PigeonInternalToString(kv.second); + } + ss << "}"; + return ss.str(); +} + +template +std::string PigeonInternalToString(const std::optional& v) { + return v ? PigeonInternalToString(*v) : "null"; +} + +template +std::string PigeonInternalToString(const std::unique_ptr& v) { + return v ? PigeonInternalToString(*v) : "null"; +} + +std::string PigeonInternalToString(const ::flutter::EncodableValue& v) { + return std::visit( + [](const auto& val) { + using T = std::decay_t; + if constexpr (std::is_same_v) { + return std::string("null"); + } else if constexpr (std::is_same_v) { + return val ? std::string("true") : std::string("false"); + } else if constexpr (std::is_same_v) { + return "\"" + val + "\""; + } else if constexpr (std::is_same_v) { + return std::string("[custom]"); + } else { + return PigeonInternalToString(val); + } + }, + v); +} + +} // namespace +// ThumbnailRequest + +ThumbnailRequest::ThumbnailRequest( + const std::string& video, + const ThumbnailFormat& format, + int64_t max_height, + int64_t max_width, + int64_t time_ms, + int64_t quality) + : video_(video), + format_(format), + max_height_(max_height), + max_width_(max_width), + time_ms_(time_ms), + quality_(quality) {} + +ThumbnailRequest::ThumbnailRequest( + const std::string& video, + const EncodableMap* headers, + const std::string* thumbnail_path, + const ThumbnailFormat& format, + int64_t max_height, + int64_t max_width, + int64_t time_ms, + int64_t quality) + : video_(video), + headers_(headers ? std::optional(*headers) : std::nullopt), + thumbnail_path_(thumbnail_path ? std::optional(*thumbnail_path) : std::nullopt), + format_(format), + max_height_(max_height), + max_width_(max_width), + time_ms_(time_ms), + quality_(quality) {} + +const std::string& ThumbnailRequest::video() const { + return video_; +} + +void ThumbnailRequest::set_video(std::string_view value_arg) { + video_ = value_arg; +} + + +const EncodableMap* ThumbnailRequest::headers() const { + return headers_ ? &(*headers_) : nullptr; +} + +void ThumbnailRequest::set_headers(const EncodableMap* value_arg) { + headers_ = value_arg ? std::optional(*value_arg) : std::nullopt; +} + +void ThumbnailRequest::set_headers(const EncodableMap& value_arg) { + headers_ = value_arg; +} + + +const std::string* ThumbnailRequest::thumbnail_path() const { + return thumbnail_path_ ? &(*thumbnail_path_) : nullptr; +} + +void ThumbnailRequest::set_thumbnail_path(const std::string_view* value_arg) { + thumbnail_path_ = value_arg ? std::optional(*value_arg) : std::nullopt; +} + +void ThumbnailRequest::set_thumbnail_path(std::string_view value_arg) { + thumbnail_path_ = value_arg; +} + + +const ThumbnailFormat& ThumbnailRequest::format() const { + return format_; +} + +void ThumbnailRequest::set_format(const ThumbnailFormat& value_arg) { + format_ = value_arg; +} + + +int64_t ThumbnailRequest::max_height() const { + return max_height_; +} + +void ThumbnailRequest::set_max_height(int64_t value_arg) { + max_height_ = value_arg; +} + + +int64_t ThumbnailRequest::max_width() const { + return max_width_; +} + +void ThumbnailRequest::set_max_width(int64_t value_arg) { + max_width_ = value_arg; +} + + +int64_t ThumbnailRequest::time_ms() const { + return time_ms_; +} + +void ThumbnailRequest::set_time_ms(int64_t value_arg) { + time_ms_ = value_arg; +} + + +int64_t ThumbnailRequest::quality() const { + return quality_; +} + +void ThumbnailRequest::set_quality(int64_t value_arg) { + quality_ = value_arg; +} + + +EncodableList ThumbnailRequest::ToEncodableList() const { + EncodableList list; + list.reserve(8); + list.push_back(EncodableValue(video_)); + list.push_back(headers_ ? EncodableValue(*headers_) : EncodableValue()); + list.push_back(thumbnail_path_ ? EncodableValue(*thumbnail_path_) : EncodableValue()); + list.push_back(CustomEncodableValue(format_)); + list.push_back(EncodableValue(max_height_)); + list.push_back(EncodableValue(max_width_)); + list.push_back(EncodableValue(time_ms_)); + list.push_back(EncodableValue(quality_)); + return list; +} + +ThumbnailRequest ThumbnailRequest::FromEncodableList(const EncodableList& list) { + ThumbnailRequest decoded( + std::get(list[0]), + std::any_cast(std::get(list[3])), + std::get(list[4]), + std::get(list[5]), + std::get(list[6]), + std::get(list[7])); + auto& encodable_headers = list[1]; + if (!encodable_headers.IsNull()) { + decoded.set_headers(std::get(encodable_headers)); + } + auto& encodable_thumbnail_path = list[2]; + if (!encodable_thumbnail_path.IsNull()) { + decoded.set_thumbnail_path(std::get(encodable_thumbnail_path)); + } + return decoded; +} + +bool ThumbnailRequest::operator==(const ThumbnailRequest& other) const { + return PigeonInternalDeepEquals(video_, other.video_) && PigeonInternalDeepEquals(headers_, other.headers_) && PigeonInternalDeepEquals(thumbnail_path_, other.thumbnail_path_) && PigeonInternalDeepEquals(format_, other.format_) && PigeonInternalDeepEquals(max_height_, other.max_height_) && PigeonInternalDeepEquals(max_width_, other.max_width_) && PigeonInternalDeepEquals(time_ms_, other.time_ms_) && PigeonInternalDeepEquals(quality_, other.quality_); +} + +bool ThumbnailRequest::operator!=(const ThumbnailRequest& other) const { + return !(*this == other); +} + +size_t ThumbnailRequest::Hash() const { + size_t result = 1; + result = result * 31 + PigeonInternalDeepHash(video_); + result = result * 31 + PigeonInternalDeepHash(headers_); + result = result * 31 + PigeonInternalDeepHash(thumbnail_path_); + result = result * 31 + PigeonInternalDeepHash(format_); + result = result * 31 + PigeonInternalDeepHash(max_height_); + result = result * 31 + PigeonInternalDeepHash(max_width_); + result = result * 31 + PigeonInternalDeepHash(time_ms_); + result = result * 31 + PigeonInternalDeepHash(quality_); + return result; +} + +std::ostream& operator<<( + std::ostream& os, + const ThumbnailRequest& obj) { + os << "ThumbnailRequest("; + os << "video: "; + os << PigeonInternalToString(obj.video_); + os << ", headers: "; + if (obj.headers_) { + os << PigeonInternalToString(*obj.headers_); + } + else { + os << "null"; + } + os << ", thumbnail_path: "; + if (obj.thumbnail_path_) { + os << PigeonInternalToString(*obj.thumbnail_path_); + } + else { + os << "null"; + } + os << ", format: "; + os << PigeonInternalToString(obj.format_); + os << ", max_height: "; + os << PigeonInternalToString(obj.max_height_); + os << ", max_width: "; + os << PigeonInternalToString(obj.max_width_); + os << ", time_ms: "; + os << PigeonInternalToString(obj.time_ms_); + os << ", quality: "; + os << PigeonInternalToString(obj.quality_); + os << ")"; + return os; +} + +size_t PigeonInternalDeepHash(const ThumbnailRequest& v) { + return v.Hash(); +} + + +PigeonInternalCodecSerializer::PigeonInternalCodecSerializer() {} + +EncodableValue PigeonInternalCodecSerializer::ReadValueOfType( + uint8_t type, + ::flutter::ByteStreamReader* stream) const { + switch (type) { + case 129: { + const auto& encodable_enum_arg = ReadValue(stream); + const int64_t enum_arg_value = encodable_enum_arg.IsNull() ? 0 : encodable_enum_arg.LongValue(); + return encodable_enum_arg.IsNull() ? EncodableValue() : CustomEncodableValue(static_cast(enum_arg_value)); + } + case 130: { + return CustomEncodableValue(ThumbnailRequest::FromEncodableList(std::get(ReadValue(stream)))); + } + default: + return ::flutter::StandardCodecSerializer::ReadValueOfType(type, stream); + } +} + +void PigeonInternalCodecSerializer::WriteValue( + const EncodableValue& value, + ::flutter::ByteStreamWriter* stream) const { + if (const CustomEncodableValue* custom_value = std::get_if(&value)) { + if (custom_value->type() == typeid(ThumbnailFormat)) { + stream->WriteByte(129); + WriteValue(EncodableValue(static_cast(std::any_cast(*custom_value))), stream); + return; + } + if (custom_value->type() == typeid(ThumbnailRequest)) { + stream->WriteByte(130); + WriteValue(EncodableValue(std::any_cast(*custom_value).ToEncodableList()), stream); + return; + } + } + ::flutter::StandardCodecSerializer::WriteValue(value, stream); +} + +/// The codec used by StreamThumbnailHostApi. +const ::flutter::StandardMessageCodec& StreamThumbnailHostApi::GetCodec() { + return ::flutter::StandardMessageCodec::GetInstance(&PigeonInternalCodecSerializer::GetInstance()); +} + +// Sets up an instance of `StreamThumbnailHostApi` to handle messages through the `binary_messenger`. +void StreamThumbnailHostApi::SetUp( + ::flutter::BinaryMessenger* binary_messenger, + StreamThumbnailHostApi* api) { + StreamThumbnailHostApi::SetUp(binary_messenger, api, ""); +} + +void StreamThumbnailHostApi::SetUp( + ::flutter::BinaryMessenger* binary_messenger, + StreamThumbnailHostApi* api, + const std::string& message_channel_suffix) { + const std::string prepended_suffix = message_channel_suffix.length() > 0 ? std::string(".") + message_channel_suffix : ""; + { + BasicMessageChannel<> channel(binary_messenger, "dev.flutter.pigeon.stream_thumbnail.StreamThumbnailHostApi.thumbnailData" + prepended_suffix, &GetCodec()); + if (api != nullptr) { + channel.SetMessageHandler([api](const EncodableValue& message, const ::flutter::MessageReply& reply) { + try { + const auto& args = std::get(message); + const auto& encodable_request_arg = args.at(0); + if (encodable_request_arg.IsNull()) { + reply(WrapError("request_arg unexpectedly null.")); + return; + } + const auto& request_arg = std::any_cast(std::get(encodable_request_arg)); + api->ThumbnailData(request_arg, [reply](ErrorOr>&& output) { + if (output.has_error()) { + reply(WrapError(output.error())); + return; + } + EncodableList wrapped; + wrapped.push_back(EncodableValue(std::move(output).TakeValue())); + reply(EncodableValue(std::move(wrapped))); + }); + } catch (const std::exception& exception) { + reply(WrapError(exception.what())); + } + }); + } else { + channel.SetMessageHandler(nullptr); + } + } + { + BasicMessageChannel<> channel(binary_messenger, "dev.flutter.pigeon.stream_thumbnail.StreamThumbnailHostApi.thumbnailFile" + prepended_suffix, &GetCodec()); + if (api != nullptr) { + channel.SetMessageHandler([api](const EncodableValue& message, const ::flutter::MessageReply& reply) { + try { + const auto& args = std::get(message); + const auto& encodable_request_arg = args.at(0); + if (encodable_request_arg.IsNull()) { + reply(WrapError("request_arg unexpectedly null.")); + return; + } + const auto& request_arg = std::any_cast(std::get(encodable_request_arg)); + api->ThumbnailFile(request_arg, [reply](ErrorOr&& output) { + if (output.has_error()) { + reply(WrapError(output.error())); + return; + } + EncodableList wrapped; + wrapped.push_back(EncodableValue(std::move(output).TakeValue())); + reply(EncodableValue(std::move(wrapped))); + }); + } catch (const std::exception& exception) { + reply(WrapError(exception.what())); + } + }); + } else { + channel.SetMessageHandler(nullptr); + } + } +} + +EncodableValue StreamThumbnailHostApi::WrapError(std::string_view error_message) { + return EncodableValue(EncodableList{ + EncodableValue(std::string(error_message)), + EncodableValue("Error"), + EncodableValue() + }); +} + +EncodableValue StreamThumbnailHostApi::WrapError(const FlutterError& error) { + return EncodableValue(EncodableList{ + EncodableValue(error.code()), + EncodableValue(error.message()), + error.details() + }); +} + +} // namespace stream_thumbnail_windows diff --git a/packages/stream_thumbnail/windows/pigeon/messages.g.h b/packages/stream_thumbnail/windows/pigeon/messages.g.h new file mode 100644 index 00000000..feffca45 --- /dev/null +++ b/packages/stream_thumbnail/windows/pigeon/messages.g.h @@ -0,0 +1,190 @@ +// Autogenerated from Pigeon (v27.3.0), do not edit directly. +// See also: https://pub.dev/packages/pigeon + +#ifndef PIGEON_MESSAGES_G_H_ +#define PIGEON_MESSAGES_G_H_ +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace stream_thumbnail_windows { + + +// Generated class from Pigeon. + +class FlutterError { + public: + explicit FlutterError(const std::string& code) + : code_(code) {} + explicit FlutterError(const std::string& code, const std::string& message) + : code_(code), message_(message) {} + explicit FlutterError(const std::string& code, const std::string& message, const ::flutter::EncodableValue& details) + : code_(code), message_(message), details_(details) {} + + const std::string& code() const { return code_; } + const std::string& message() const { return message_; } + const ::flutter::EncodableValue& details() const { return details_; } + + private: + std::string code_; + std::string message_; + ::flutter::EncodableValue details_; +}; + +template class ErrorOr { + public: + ErrorOr(const T& rhs) : v_(rhs) {} + ErrorOr(const T&& rhs) : v_(std::move(rhs)) {} + ErrorOr(const FlutterError& rhs) : v_(rhs) {} + ErrorOr(const FlutterError&& rhs) : v_(std::move(rhs)) {} + + bool has_error() const { return std::holds_alternative(v_); } + const T& value() const { return std::get(v_); }; + const FlutterError& error() const { return std::get(v_); }; + + private: + friend class StreamThumbnailHostApi; + ErrorOr() = default; + T TakeValue() && { return std::get(std::move(v_)); } + + std::variant v_; +}; + + +// Wire representation of the image format for a generated thumbnail. +enum class ThumbnailFormat { + kJpeg = 0, + kPng = 1, + kWebp = 2 +}; + + +// A single thumbnail generation request sent to the native platform. +// +// Generated class from Pigeon that represents data sent in messages. +class ThumbnailRequest { + public: + // Constructs an object setting all non-nullable fields. + explicit ThumbnailRequest( + const std::string& video, + const ThumbnailFormat& format, + int64_t max_height, + int64_t max_width, + int64_t time_ms, + int64_t quality); + + // Constructs an object setting all fields. + explicit ThumbnailRequest( + const std::string& video, + const ::flutter::EncodableMap* headers, + const std::string* thumbnail_path, + const ThumbnailFormat& format, + int64_t max_height, + int64_t max_width, + int64_t time_ms, + int64_t quality); + + const std::string& video() const; + void set_video(std::string_view value_arg); + + const ::flutter::EncodableMap* headers() const; + void set_headers(const ::flutter::EncodableMap* value_arg); + void set_headers(const ::flutter::EncodableMap& value_arg); + + const std::string* thumbnail_path() const; + void set_thumbnail_path(const std::string_view* value_arg); + void set_thumbnail_path(std::string_view value_arg); + + const ThumbnailFormat& format() const; + void set_format(const ThumbnailFormat& value_arg); + + int64_t max_height() const; + void set_max_height(int64_t value_arg); + + int64_t max_width() const; + void set_max_width(int64_t value_arg); + + int64_t time_ms() const; + void set_time_ms(int64_t value_arg); + + int64_t quality() const; + void set_quality(int64_t value_arg); + + bool operator==(const ThumbnailRequest& other) const; + bool operator!=(const ThumbnailRequest& other) const; + /// Returns a hash code value for the object. This method is supported for the benefit of hash tables. + size_t Hash() const; + /// Stream output operator for formatted string representation. + friend std::ostream& operator<<(std::ostream& os, const ThumbnailRequest& obj); + private: + static ThumbnailRequest FromEncodableList(const ::flutter::EncodableList& list); + ::flutter::EncodableList ToEncodableList() const; + friend class StreamThumbnailHostApi; + friend class PigeonInternalCodecSerializer; + std::string video_; + std::optional<::flutter::EncodableMap> headers_; + std::optional thumbnail_path_; + ThumbnailFormat format_; + int64_t max_height_; + int64_t max_width_; + int64_t time_ms_; + int64_t quality_; +}; + + +class PigeonInternalCodecSerializer : public ::flutter::StandardCodecSerializer { + public: + PigeonInternalCodecSerializer(); + inline static PigeonInternalCodecSerializer& GetInstance() { + static PigeonInternalCodecSerializer sInstance; + return sInstance; + } + + void WriteValue( + const ::flutter::EncodableValue& value, + ::flutter::ByteStreamWriter* stream) const override; + protected: + ::flutter::EncodableValue ReadValueOfType( + uint8_t type, + ::flutter::ByteStreamReader* stream) const override; +}; + +// Generated interface from Pigeon that represents a handler of messages from Flutter. +class StreamThumbnailHostApi { + public: + StreamThumbnailHostApi(const StreamThumbnailHostApi&) = delete; + StreamThumbnailHostApi& operator=(const StreamThumbnailHostApi&) = delete; + virtual ~StreamThumbnailHostApi() {} + // Generates a thumbnail for [ThumbnailRequest.video] and returns its bytes. + virtual void ThumbnailData( + const ThumbnailRequest& request, + std::function> reply)> result) = 0; + // Generates a thumbnail for [ThumbnailRequest.video] and returns the path + // it was written to. + virtual void ThumbnailFile( + const ThumbnailRequest& request, + std::function reply)> result) = 0; + + // The codec used by StreamThumbnailHostApi. + static const ::flutter::StandardMessageCodec& GetCodec(); + // Sets up an instance of `StreamThumbnailHostApi` to handle messages through the `binary_messenger`. + static void SetUp( + ::flutter::BinaryMessenger* binary_messenger, + StreamThumbnailHostApi* api); + static void SetUp( + ::flutter::BinaryMessenger* binary_messenger, + StreamThumbnailHostApi* api, + const std::string& message_channel_suffix); + static ::flutter::EncodableValue WrapError(std::string_view error_message); + static ::flutter::EncodableValue WrapError(const FlutterError& error); + protected: + StreamThumbnailHostApi() = default; +}; +} // namespace stream_thumbnail_windows +#endif // PIGEON_MESSAGES_G_H_ diff --git a/packages/stream_thumbnail/windows/stream_thumbnail_plugin.cpp b/packages/stream_thumbnail/windows/stream_thumbnail_plugin.cpp new file mode 100644 index 00000000..268c1ad9 --- /dev/null +++ b/packages/stream_thumbnail/windows/stream_thumbnail_plugin.cpp @@ -0,0 +1,440 @@ +#include "stream_thumbnail_plugin.h" + +// This must be included before many other Windows headers. +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include + +using Microsoft::WRL::ComPtr; +using stream_thumbnail_windows::ErrorOr; +using stream_thumbnail_windows::FlutterError; +using stream_thumbnail_windows::ThumbnailFormat; +using stream_thumbnail_windows::ThumbnailRequest; + +namespace stream_thumbnail { + +namespace { + +// A generation failure that couldn't produce a frame/encode, or a failure +// writing the encoded thumbnail to disk. +class ThumbnailException : public std::runtime_error { + public: + ThumbnailException(std::string code, const std::string &message) + : std::runtime_error(message), code_(std::move(code)) {} + + const std::string &code() const { return code_; } + + private: + std::string code_; +}; + +std::wstring Utf8ToWide(const std::string &utf8) { + if (utf8.empty()) return std::wstring(); + const int size = MultiByteToWideChar(CP_UTF8, 0, utf8.data(), static_cast(utf8.size()), nullptr, 0); + std::wstring wide(size, 0); + MultiByteToWideChar(CP_UTF8, 0, utf8.data(), static_cast(utf8.size()), wide.data(), size); + return wide; +} + +std::string WideToUtf8(const std::wstring &wide) { + if (wide.empty()) return std::string(); + const int size = + WideCharToMultiByte(CP_UTF8, 0, wide.data(), static_cast(wide.size()), nullptr, 0, nullptr, nullptr); + std::string utf8(size, 0); + WideCharToMultiByte(CP_UTF8, 0, wide.data(), static_cast(wide.size()), utf8.data(), size, nullptr, nullptr); + return utf8; +} + +bool IsLocalPath(const std::string &video) { + if (video.size() >= 2 && std::isalpha(static_cast(video[0])) && video[1] == ':') return true; + if (video.rfind("\\\\", 0) == 0) return true; + if (video.rfind("/", 0) == 0) return true; + if (video.rfind("file://", 0) == 0) return true; + return false; +} + +// Strips a leading "file://" prefix, if present. +std::string VideoPath(const std::string &video) { + if (video.rfind("file://", 0) == 0) return video.substr(7); + return video; +} + +std::string FileExtension(ThumbnailFormat format) { + switch (format) { + case ThumbnailFormat::kJpeg: + return "jpg"; + case ThumbnailFormat::kPng: + return "png"; + case ThumbnailFormat::kWebp: + return "webp"; + } + return "jpg"; +} + +// A single decoded video frame as a flat, contiguous BGRX (32bpp) buffer. +struct DecodedFrame { + std::vector pixels; + UINT32 width = 0; + UINT32 height = 0; + LONG stride = 0; +}; + +// Decodes a single frame from `video` at `time_ms` via Media Foundation. +// +// NOTE: unlike AVFoundation (iOS/macOS) and MediaMetadataRetriever (Android), +// IMFSourceReader has no simple way to attach custom HTTP headers to a remote +// URL, so `request.headers()` is not applied on Windows. +DecodedFrame DecodeFrame(const std::string &video, int64_t time_ms) { + ComPtr attributes; + ComPtr reader; + ComPtr type; + ComPtr current_type; + ComPtr sample; + ComPtr buffer; + + HRESULT hr = MFCreateAttributes(&attributes, 1); + if (SUCCEEDED(hr)) { + hr = attributes->SetUINT32(MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING, TRUE); + } + + const std::wstring wide_path = Utf8ToWide(VideoPath(video)); + if (SUCCEEDED(hr)) { + hr = MFCreateSourceReaderFromURL(wide_path.c_str(), attributes.Get(), &reader); + } + if (FAILED(hr)) { + throw ThumbnailException("THUMBNAIL_ERROR", "Failed to open the video."); + } + + // Ask the source reader to hand back progressive RGB32 frames; its + // internal video processor performs any necessary YUV -> RGB conversion. + hr = MFCreateMediaType(&type); + if (SUCCEEDED(hr)) hr = type->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video); + if (SUCCEEDED(hr)) hr = type->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_RGB32); + if (SUCCEEDED(hr)) { + hr = reader->SetCurrentMediaType(static_cast(MF_SOURCE_READER_FIRST_VIDEO_STREAM), nullptr, type.Get()); + } + if (SUCCEEDED(hr)) { + hr = reader->SetStreamSelection(static_cast(MF_SOURCE_READER_FIRST_VIDEO_STREAM), TRUE); + } + if (FAILED(hr)) { + throw ThumbnailException("THUMBNAIL_ERROR", "Failed to configure the video decoder."); + } + + UINT32 width = 0, height = 0; + hr = reader->GetCurrentMediaType(static_cast(MF_SOURCE_READER_FIRST_VIDEO_STREAM), ¤t_type); + if (SUCCEEDED(hr)) hr = MFGetAttributeSize(current_type.Get(), MF_MT_FRAME_SIZE, &width, &height); + if (FAILED(hr) || width == 0 || height == 0) { + throw ThumbnailException("THUMBNAIL_ERROR", "Failed to read the video's frame size."); + } + + // Seek, if possible; `time_ms` is already platform-normalized (never + // negative) on the Dart side. Seek failures are non-fatal — fall back to + // whatever frame ReadSample returns first. + if (time_ms > 0) { + PROPVARIANT position; + PropVariantInit(&position); + position.vt = VT_I8; + position.hVal.QuadPart = time_ms * 10000LL; // milliseconds -> 100ns units. + reader->SetCurrentPosition(GUID_NULL, position); + PropVariantClear(&position); + } + + DWORD flags = 0; + hr = reader->ReadSample(static_cast(MF_SOURCE_READER_FIRST_VIDEO_STREAM), 0, nullptr, &flags, nullptr, + &sample); + if (FAILED(hr) || !sample) { + throw ThumbnailException("THUMBNAIL_ERROR", "Failed to read a video frame."); + } + + hr = sample->ConvertToContiguousBuffer(&buffer); + if (FAILED(hr)) { + throw ThumbnailException("THUMBNAIL_ERROR", "Failed to read the decoded frame buffer."); + } + + BYTE *data = nullptr; + DWORD data_length = 0; + hr = buffer->Lock(&data, nullptr, &data_length); + if (FAILED(hr)) { + throw ThumbnailException("THUMBNAIL_ERROR", "Failed to lock the decoded frame buffer."); + } + + DecodedFrame frame; + frame.width = width; + frame.height = height; + frame.stride = static_cast(width) * 4; // RGB32 is a tightly-packed 32bpp format. + frame.pixels.assign(data, data + data_length); + buffer->Unlock(); + + return frame; +} + +// Computes the output size for `frame`, honoring `max_width`/`max_height` +// (<= 0 keeps the source resolution), preserving aspect ratio. +void ScaledSize(const DecodedFrame &frame, int64_t max_width, int64_t max_height, UINT *out_width, + UINT *out_height) { + if (max_width <= 0 && max_height <= 0) { + *out_width = frame.width; + *out_height = frame.height; + return; + } + const double aspect = static_cast(frame.width) / static_cast(frame.height); + double width = max_width > 0 ? static_cast(max_width) : max_height * aspect; + double height = max_height > 0 ? static_cast(max_height) : max_width / aspect; + if (max_width > 0 && max_height > 0 && width / aspect > height) { + width = height * aspect; + } else if (max_width > 0 && max_height > 0) { + height = width / aspect; + } + *out_width = static_cast(width); + *out_height = static_cast(height); +} + +// Encodes `frame` as jpeg/png into `stream` via WIC. `quality` (0-100) only +// applies to jpeg. +void EncodeToStream(const DecodedFrame &frame, ThumbnailFormat format, int64_t max_width, int64_t max_height, + int64_t quality, IStream *stream) { + ComPtr factory; + HRESULT hr = CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&factory)); + if (FAILED(hr)) { + throw ThumbnailException("THUMBNAIL_ERROR", "Failed to create the imaging factory."); + } + + ComPtr bitmap; + // Media Foundation's RGB32 is a 32bpp BGR format with an unused byte (no + // meaningful alpha channel) — WICPixelFormat32bppBGR matches it exactly. + hr = factory->CreateBitmapFromMemory(frame.width, frame.height, GUID_WICPixelFormat32bppBGR, frame.stride, + static_cast(frame.pixels.size()), + const_cast(frame.pixels.data()), &bitmap); + if (FAILED(hr)) { + throw ThumbnailException("THUMBNAIL_ERROR", "Failed to wrap the decoded frame."); + } + + ComPtr source = bitmap; + UINT target_width = 0, target_height = 0; + ScaledSize(frame, max_width, max_height, &target_width, &target_height); + if (target_width != frame.width || target_height != frame.height) { + ComPtr scaler; + hr = factory->CreateBitmapScaler(&scaler); + if (SUCCEEDED(hr)) { + hr = scaler->Initialize(bitmap.Get(), target_width, target_height, WICBitmapInterpolationModeFant); + } + if (FAILED(hr)) { + throw ThumbnailException("THUMBNAIL_ERROR", "Failed to scale the decoded frame."); + } + source = scaler; + } + + const GUID container_format = format == ThumbnailFormat::kPng ? GUID_ContainerFormatPng : GUID_ContainerFormatJpeg; + + ComPtr encoder; + hr = factory->CreateEncoder(container_format, nullptr, &encoder); + if (SUCCEEDED(hr)) hr = encoder->Initialize(stream, WICBitmapEncoderNoCache); + + ComPtr frame_encode; + ComPtr properties; + if (SUCCEEDED(hr)) hr = encoder->CreateNewFrame(&frame_encode, &properties); + + if (SUCCEEDED(hr) && format == ThumbnailFormat::kJpeg) { + PROPBAG2 option = {}; + option.pstrName = const_cast(L"ImageQuality"); + VARIANT value; + VariantInit(&value); + value.vt = VT_R4; + value.fltVal = static_cast(quality) / 100.0f; + properties->Write(1, &option, &value); + } + + if (SUCCEEDED(hr)) hr = frame_encode->Initialize(properties.Get()); + if (SUCCEEDED(hr)) hr = frame_encode->SetSize(target_width, target_height); + + WICPixelFormatGUID pixel_format = GUID_WICPixelFormat24bppBGR; + if (SUCCEEDED(hr)) hr = frame_encode->SetPixelFormat(&pixel_format); + + ComPtr converter; + if (SUCCEEDED(hr)) hr = factory->CreateFormatConverter(&converter); + if (SUCCEEDED(hr)) { + hr = converter->Initialize(source.Get(), pixel_format, WICBitmapDitherTypeNone, nullptr, 0.0, + WICBitmapPaletteTypeCustom); + } + if (SUCCEEDED(hr)) hr = frame_encode->WriteSource(converter.Get(), nullptr); + if (SUCCEEDED(hr)) hr = frame_encode->Commit(); + if (SUCCEEDED(hr)) hr = encoder->Commit(); + + if (FAILED(hr)) { + throw ThumbnailException("THUMBNAIL_ERROR", "Failed to encode the thumbnail image."); + } +} + +std::vector GenerateThumbnailData(const ThumbnailRequest &request) { + if (request.format() == ThumbnailFormat::kWebp) { + throw ThumbnailException("UNSUPPORTED_FORMAT", "WebP is not yet supported on Windows."); + } + + const DecodedFrame frame = DecodeFrame(request.video(), request.time_ms()); + + ComPtr stream; + HRESULT hr = CreateStreamOnHGlobal(nullptr, TRUE, &stream); + if (FAILED(hr)) { + throw ThumbnailException("THUMBNAIL_ERROR", "Failed to allocate an output buffer."); + } + + EncodeToStream(frame, request.format(), request.max_width(), request.max_height(), request.quality(), + stream.Get()); + + STATSTG stats = {}; + stream->Stat(&stats, STATFLAG_NONAME); + const ULONG size = static_cast(stats.cbSize.QuadPart); + + LARGE_INTEGER zero = {}; + stream->Seek(zero, STREAM_SEEK_SET, nullptr); + + std::vector bytes(size); + ULONG read = 0; + stream->Read(bytes.data(), size, &read); + bytes.resize(read); + return bytes; +} + +std::string WriteThumbnailFile(const ThumbnailRequest &request) { + const std::vector data = GenerateThumbnailData(request); + const std::string ext = FileExtension(request.format()); + const std::string video_path = VideoPath(request.video()); + + std::string save_path = request.thumbnail_path() != nullptr ? *request.thumbnail_path() : std::string(); + if (save_path.empty() && !IsLocalPath(request.video())) { + wchar_t temp_path[MAX_PATH]; + GetTempPathW(MAX_PATH, temp_path); + save_path = WideToUtf8(temp_path); + } + + const size_t dot = video_path.find_last_of('.'); + const std::string base = dot == std::string::npos ? video_path : video_path.substr(0, dot); + + std::string full_path; + if (!save_path.empty()) { + const bool ends_with_ext = + save_path.size() >= ext.size() && save_path.compare(save_path.size() - ext.size(), ext.size(), ext) == 0; + if (ends_with_ext) { + full_path = save_path; + } else { + const size_t slash = base.find_last_of("/\\"); + const std::string file_name = (slash == std::string::npos ? base : base.substr(slash + 1)) + "." + ext; + const bool trailing_slash = save_path.back() == '/' || save_path.back() == '\\'; + full_path = trailing_slash ? save_path + file_name : save_path + "\\" + file_name; + } + } else { + full_path = base + "." + ext; + } + + const std::wstring wide_path = Utf8ToWide(full_path); + ComPtr file_stream; + HRESULT hr = SHCreateStreamOnFileEx(wide_path.c_str(), STGM_CREATE | STGM_WRITE | STGM_SHARE_EXCLUSIVE, 0, TRUE, + nullptr, &file_stream); + ULONG written = 0; + if (SUCCEEDED(hr)) hr = file_stream->Write(data.data(), static_cast(data.size()), &written); + if (FAILED(hr) || written != data.size()) { + throw ThumbnailException("WRITE_ERROR", "Failed to write the thumbnail to disk."); + } + + return full_path; +} + +} // namespace + +// static +void StreamThumbnailPlugin::RegisterWithRegistrar(flutter::PluginRegistrarWindows *registrar) { + auto plugin = std::make_unique(); + stream_thumbnail_windows::StreamThumbnailHostApi::SetUp(registrar->messenger(), plugin.get()); + registrar->AddPlugin(std::move(plugin)); +} + +StreamThumbnailPlugin::StreamThumbnailPlugin() { MFStartup(MF_VERSION); } + +StreamThumbnailPlugin::~StreamThumbnailPlugin() { + // Media Foundation must not be torn down underneath a decode that is still + // running, so every in-flight request has to finish first. + std::vector> workers; + { + std::lock_guard lock(mutex_); + workers = std::move(workers_); + } + for (const auto &worker : workers) { + if (worker->thread.joinable()) worker->thread.join(); + } + + MFShutdown(); +} + +// Replying from a worker thread is supported: the client wrapper's BinaryReply +// locks the messenger and drops the reply if the engine is already gone. +void StreamThumbnailPlugin::RunOnWorker(std::function work) { + std::lock_guard lock(mutex_); + ReapFinishedWorkers(); + + auto worker = std::make_unique(); + Worker *worker_ptr = worker.get(); + worker->thread = std::thread([worker_ptr, work = std::move(work)]() { + const bool co_initialized = SUCCEEDED(CoInitializeEx(nullptr, COINIT_MULTITHREADED)); + work(); + if (co_initialized) CoUninitialize(); + worker_ptr->done.store(true); + }); + workers_.push_back(std::move(worker)); +} + +void StreamThumbnailPlugin::ReapFinishedWorkers() { + for (auto it = workers_.begin(); it != workers_.end();) { + if ((*it)->done.load()) { + (*it)->thread.join(); + it = workers_.erase(it); + } else { + ++it; + } + } +} + +void StreamThumbnailPlugin::ThumbnailData(const ThumbnailRequest &request, + std::function> reply)> result) { + RunOnWorker([request, result]() { + try { + result(ErrorOr>(GenerateThumbnailData(request))); + } catch (const ThumbnailException &e) { + result(ErrorOr>(FlutterError(e.code(), e.what()))); + } catch (const std::exception &e) { + // Anything unexpected (std::bad_alloc, ...) must still come back as a + // Flutter-side error: an exception escaping the worker would terminate + // the process, and the request would never be replied to. + result(ErrorOr>(FlutterError("THUMBNAIL_ERROR", e.what()))); + } + }); +} + +void StreamThumbnailPlugin::ThumbnailFile(const ThumbnailRequest &request, + std::function reply)> result) { + RunOnWorker([request, result]() { + try { + result(ErrorOr(WriteThumbnailFile(request))); + } catch (const ThumbnailException &e) { + result(ErrorOr(FlutterError(e.code(), e.what()))); + } catch (const std::exception &e) { + result(ErrorOr(FlutterError("THUMBNAIL_ERROR", e.what()))); + } + }); +} + +} // namespace stream_thumbnail diff --git a/packages/stream_thumbnail/windows/stream_thumbnail_plugin.h b/packages/stream_thumbnail/windows/stream_thumbnail_plugin.h new file mode 100644 index 00000000..6a761f8e --- /dev/null +++ b/packages/stream_thumbnail/windows/stream_thumbnail_plugin.h @@ -0,0 +1,57 @@ +#ifndef FLUTTER_PLUGIN_STREAM_THUMBNAIL_PLUGIN_H_ +#define FLUTTER_PLUGIN_STREAM_THUMBNAIL_PLUGIN_H_ + +#include + +#include +#include +#include +#include +#include +#include + +#include "pigeon/messages.g.h" + +namespace stream_thumbnail { + +class StreamThumbnailPlugin : public flutter::Plugin, + public stream_thumbnail_windows::StreamThumbnailHostApi { + public: + static void RegisterWithRegistrar(flutter::PluginRegistrarWindows *registrar); + + StreamThumbnailPlugin(); + virtual ~StreamThumbnailPlugin(); + + // Disallow copy and assign. + StreamThumbnailPlugin(const StreamThumbnailPlugin &) = delete; + StreamThumbnailPlugin &operator=(const StreamThumbnailPlugin &) = delete; + + // stream_thumbnail_windows::StreamThumbnailHostApi + void ThumbnailData( + const stream_thumbnail_windows::ThumbnailRequest &request, + std::function> reply)> result) override; + void ThumbnailFile( + const stream_thumbnail_windows::ThumbnailRequest &request, + std::function reply)> result) override; + + private: + // One in-flight request. `done` is set by the worker as its very last action, + // so finished workers can be spotted and joined without blocking. + struct Worker { + std::thread thread; + std::atomic done{false}; + }; + + // Runs `work` on a tracked worker thread, with COM initialized for it. + void RunOnWorker(std::function work); + + // Joins and drops every worker that has finished. Caller must hold `mutex_`. + void ReapFinishedWorkers(); + + std::mutex mutex_; + std::vector> workers_; +}; + +} // namespace stream_thumbnail + +#endif // FLUTTER_PLUGIN_STREAM_THUMBNAIL_PLUGIN_H_ diff --git a/packages/stream_thumbnail/windows/stream_thumbnail_plugin_c_api.cpp b/packages/stream_thumbnail/windows/stream_thumbnail_plugin_c_api.cpp new file mode 100644 index 00000000..76cae9b0 --- /dev/null +++ b/packages/stream_thumbnail/windows/stream_thumbnail_plugin_c_api.cpp @@ -0,0 +1,12 @@ +#include "include/stream_thumbnail/stream_thumbnail_plugin_c_api.h" + +#include + +#include "stream_thumbnail_plugin.h" + +void StreamThumbnailPluginCApiRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar) { + stream_thumbnail::StreamThumbnailPlugin::RegisterWithRegistrar( + flutter::PluginRegistrarManager::GetInstance() + ->GetRegistrar(registrar)); +}