commit 55940f0d52a69ab1959ec80118956ddbeca085f9 Author: Matt Bruce Date: Sun Jan 4 09:41:00 2026 -0600 Initial commit of local MijickCamera package diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..02e39f6 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @FulcrumOne diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..916f081 --- /dev/null +++ b/.github/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +team@mijick.com. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..684e27f --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1 @@ +Coming soon... diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..e0b3e50 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,14 @@ +# These are supported funding model platforms + +github: mijick +patreon: # Replace with a single Patreon username +open_collective: # Replace with a single Open Collective username +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry +polar: # Replace with a single Polar username +buy_me_a_coffee: mijick +custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..d06b778 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: ❓ Help and Support Discord Channel + url: https://discord.com/invite/dT5V7nm5SC + about: Please ask and answer questions here diff --git a/.github/ISSUE_TEMPLATE/🚀-feature-request.md b/.github/ISSUE_TEMPLATE/🚀-feature-request.md new file mode 100644 index 0000000..c543b5c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/🚀-feature-request.md @@ -0,0 +1,18 @@ +--- +name: "\U0001F680 Feature Request" +about: If you have a feature request +title: "[FREQ]" +labels: 'feature' +projects: "Mijick/15" +assignees: FulcrumOne + +--- + +## Context +What are you trying to do and how would you want to do it differently? Is it something you currently you cannot do? Is this related to an issue/problem? + +## Alternatives +Can you achieve the same result doing it in an alternative way? Is the alternative considerable? + +## If the feature request is approved, would you be willing to submit a PR? +Yes / No _(Help can be provided if you need assistance submitting a PR)_ diff --git a/.github/ISSUE_TEMPLATE/🦟-bug-report.md b/.github/ISSUE_TEMPLATE/🦟-bug-report.md new file mode 100644 index 0000000..d784b66 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/🦟-bug-report.md @@ -0,0 +1,42 @@ +--- +name: "\U0001F99F Bug Report" +about: If something isn't working +title: "[BUG]" +labels: 'bug' +projects: "Mijick/15" +assignees: FulcrumOne, jay-jay-lama + +--- + +## Prerequisites +- [ ] I checked the [documentation](https://github.com/Mijick/Camera/wiki) and found no answer +- [ ] I checked to make sure that this issue has not already been filed + +## Expected Behavior +Please describe the behavior you are expecting + +## Current Behavior +What is the current behavior? + +## Steps to Reproduce +Please provide detailed steps for reproducing the issue. +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +## Code Sample +If you can, please include a code sample that we can use to debug the bug. + +## Screenshots +If applicable, add screenshots to help explain your problem. + +## Context +Please provide any relevant information about your setup. This is important in case the issue is not reproducible except for under certain conditions. + +| Name | Version | +| ------| ---------| +| SDK | e.g. 3.0.0 | +| Xcode | e.g. 14.0 | +| Operating System | e.g. iOS 18.0 | +| Device | e.g. iPhone 14 Pro | diff --git a/.github/workflows/publish-to-cocoapods.yml b/.github/workflows/publish-to-cocoapods.yml new file mode 100644 index 0000000..762c2cb --- /dev/null +++ b/.github/workflows/publish-to-cocoapods.yml @@ -0,0 +1,14 @@ +name: Publish to Cocoapods +on: + deployment: + workflow_dispatch: +jobs: + build: + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + - name: Publish to Cocoapods + env: + COCOAPODS_TRUNK_TOKEN: ${{ secrets.COCOAPODS_TRUNK_TOKEN }} + run: | + pod trunk push MijickCamera.podspec diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml new file mode 100644 index 0000000..8c6044d --- /dev/null +++ b/.github/workflows/run-tests.yml @@ -0,0 +1,14 @@ +name: Run Tests +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + workflow_dispatch: +jobs: + build: + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + - name: Run tests + run: xcodebuild test -scheme MijickCamera -destination 'platform=iOS Simulator,OS=18.0,name=iPhone 16 Pro' diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b117785 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc +*.xcscheme +*.plist +.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..590abe8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright ©2024 Mijick + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/MijickCamera.podspec b/MijickCamera.podspec new file mode 100644 index 0000000..ece36d8 --- /dev/null +++ b/MijickCamera.podspec @@ -0,0 +1,21 @@ +Pod::Spec.new do |s| + s.name = 'MijickCamera' + s.summary = 'Significantly reduces implementation time and effort. Keeps your code clean.' + s.description = <<-DESC + Camera made simple. The ultimate camera library that significantly reduces implementation time and effort. Written with and for SwiftUI. + DESC + + s.version = '3.0.1' + s.ios.deployment_target = '14.0' + s.swift_version = '6.0' + + s.source_files = 'Sources/**/*.{swift}' + s.resources = 'Sources/Internal/Assets/*.{xcassets, json}' + s.dependency 'MijickTimer' + s.frameworks = 'SwiftUI', 'Foundation', 'AVKit', 'AVFoundation', 'MijickTimer' + + s.homepage = 'https://github.com/Mijick/Camera.git' + s.license = { :type => 'Apache License 2.0', :file => 'LICENSE' } + s.author = { 'Tomasz Kurylik from Mijick' => 'tomasz.kurylik@mijick.com' } + s.source = { :git => 'https://github.com/Mijick/Camera.git', :tag => s.version.to_s } +end diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..7b8b35c --- /dev/null +++ b/Package.swift @@ -0,0 +1,22 @@ +// swift-tools-version: 6.0 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "MijickCamera", + platforms: [ + .iOS(.v17) + ], + products: [ + .library(name: "MijickCamera", targets: ["MijickCamera"]), + ], + dependencies: [ + .package(url: "https://github.com/Mijick/Timer", exact: "2.0.0") + ], + targets: [ + .target(name: "MijickCamera", dependencies: [.product(name: "MijickTimer", package: "Timer")], path: "Sources", resources: [.process("Internal/Assets")]), + .testTarget(name: "MijickCameraTests", dependencies: ["MijickCamera"], path: "Tests") + ], + swiftLanguageModes: [.v6] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..d5c9967 --- /dev/null +++ b/README.md @@ -0,0 +1,226 @@ + +

+ + MijickCamera Hero + +

+ + +

+

Camera made simple

+

Significantly reduces implementation time and effort. Keeps your code clean.

+

+ + +

+ Try demo we prepared + | + Framework documentation + | + Roadmap +

+ +
+ + +

+ Labels +

+ +
+ + + + + + + + + + + + + + + + + + + +
Camera PositionMedia CapturingGesturesFilters
+ + + + + + + +
+ +

+ + +

+ + + Visit our Website + + + + Join us on Discord + + + + Follow us on LinkedIn + + + + See our other frameworks + + + + Read us on Medium + + + + Buy us a coffee + +

+ + +# ✨ Features + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
🙏🏻Automatically handles permissions
🖼️Image capture
🎬️Video capture (with or without sound)
📸Camera position changes
🔍️Supports manual zoom
👁️Supports manual focus
🎞️Changeable frame rate
📺️Changeable camera resolution
🙈Camera filters
🔦Torch
📸Flash
⏱️Other camera settings (exposure duration, target bias, ISO, HDR mode and more)
☢️Displays error screen if permissions are not granted
🖼️Displays captured media screen
📱Modern and minimalistic UI
🕺Beautiful animations
🚧Fully customizable screens
🤏🏼Gestures support
📲Blocks screen orientation change
⚡️Supports Swift 6
🚀... and others
+ + +# ☀️ Why MijickCamera? +The main problem we wanted to solve was the complexity of implementing camera into Swift projects; to get a camera view, you either have to accept a number of trade-offs or spend hours wrestling with the complexity of the AVKit framework. Here is why we think we have successfully solved the problem: + +

+

The power of simplicity

+

Thanks to a modern and minimalistic UI and a thoughtfully designed public API, the most common use cases can be solved with just a few lines of code.

+ Code Example 1 +

+ +

+

Three in one

+

MCamera contains three screens - Error Screen, Captured Media Screen and Camera Screen - making the process of handling camera states super easy. Moreover, MijickCamera automatically manages the entire workflow, from requesting camera permissions to displaying the results of camera captures!

+ Code Example 4 +

+ +

+

Engineered for limitless creativity

+

Every application is a special one, and we at Mijick know this very well, thus we have given you the possibility to customize each of the three screens that constitute MCamera.

+ Code Example 1 +

+ + +### There is much more besides: +- Advanced camera controls. +- Gesture support. +- Thoroughly designed animations. +- Supports Swift 6.0. +- ... and much more. + + + +# 🚀 How to use it? +Visit the framework's [documentation page](https://link.mijick.com/camera-wiki) to learn how to integrate your project with **MijickCamera**. + + +# 🍀 Community +Join the welcoming community of developers on [Discord](https://link.mijick.com/discord). + + +# 🌼 Contribute +To contribute a feature or idea to **MijickCamera**, create an [issue](https://github.com/Mijick/Camera/issues/new?assignees=FulcrumOne&labels=state%3A+inactive%2C+type%3A+feature&projects=&template=🚀-feature-request.md&title=%5BFREQ%5D) explaining your idea or bring it up on [Discord](https://discord.com/invite/dT5V7nm5SC).
+If you find a bug, please create an [issue](https://github.com/Mijick/Camera/issues/new?assignees=FulcrumOne%2C+jay-jay-lama&labels=state%3A+inactive%2C+type%3A+bug&projects=&template=🦟-bug-report.md&title=%5BBUG%5D).
+If you would like to contribute, please refer to the [Contribution Guidelines](https://github.com/Mijick/Camera/blob/main/.github/CONTRIBUTING.md). + + +# 💜 Sponsor our work +Support our work by [becoming a backer](https://link.mijick.com/buymeacoffee). diff --git a/Sources/Internal/Assets/Colors.xcassets/Contents.json b/Sources/Internal/Assets/Colors.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Sources/Internal/Assets/Colors.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/Internal/Assets/Colors.xcassets/mijick-background-inverted.colorset/Contents.json b/Sources/Internal/Assets/Colors.xcassets/mijick-background-inverted.colorset/Contents.json new file mode 100644 index 0000000..27eef21 --- /dev/null +++ b/Sources/Internal/Assets/Colors.xcassets/mijick-background-inverted.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xEF", + "green" : "0xEF", + "red" : "0xF1" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/Internal/Assets/Colors.xcassets/mijick-background-primary-50.colorset/Contents.json b/Sources/Internal/Assets/Colors.xcassets/mijick-background-primary-50.colorset/Contents.json new file mode 100644 index 0000000..efda8b1 --- /dev/null +++ b/Sources/Internal/Assets/Colors.xcassets/mijick-background-primary-50.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.500", + "blue" : "0x05", + "green" : "0x05", + "red" : "0x05" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/Internal/Assets/Colors.xcassets/mijick-background-primary-80.colorset/Contents.json b/Sources/Internal/Assets/Colors.xcassets/mijick-background-primary-80.colorset/Contents.json new file mode 100644 index 0000000..ce64275 --- /dev/null +++ b/Sources/Internal/Assets/Colors.xcassets/mijick-background-primary-80.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.800", + "blue" : "0x05", + "green" : "0x05", + "red" : "0x05" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/Internal/Assets/Colors.xcassets/mijick-background-primary.colorset/Contents.json b/Sources/Internal/Assets/Colors.xcassets/mijick-background-primary.colorset/Contents.json new file mode 100644 index 0000000..2abf5f1 --- /dev/null +++ b/Sources/Internal/Assets/Colors.xcassets/mijick-background-primary.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x05", + "green" : "0x05", + "red" : "0x05" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/Internal/Assets/Colors.xcassets/mijick-background-red.colorset/Contents.json b/Sources/Internal/Assets/Colors.xcassets/mijick-background-red.colorset/Contents.json new file mode 100644 index 0000000..13298d4 --- /dev/null +++ b/Sources/Internal/Assets/Colors.xcassets/mijick-background-red.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x3A", + "green" : "0x36", + "red" : "0xBC" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/Internal/Assets/Colors.xcassets/mijick-background-secondary.colorset/Contents.json b/Sources/Internal/Assets/Colors.xcassets/mijick-background-secondary.colorset/Contents.json new file mode 100644 index 0000000..0444a5d --- /dev/null +++ b/Sources/Internal/Assets/Colors.xcassets/mijick-background-secondary.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x20", + "green" : "0x20", + "red" : "0x20" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/Internal/Assets/Colors.xcassets/mijick-background-yellow.colorset/Contents.json b/Sources/Internal/Assets/Colors.xcassets/mijick-background-yellow.colorset/Contents.json new file mode 100644 index 0000000..3aa0063 --- /dev/null +++ b/Sources/Internal/Assets/Colors.xcassets/mijick-background-yellow.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x40", + "green" : "0xCA", + "red" : "0xF3" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/Internal/Assets/Colors.xcassets/mijick-text-brand.colorset/Contents.json b/Sources/Internal/Assets/Colors.xcassets/mijick-text-brand.colorset/Contents.json new file mode 100644 index 0000000..5f6685b --- /dev/null +++ b/Sources/Internal/Assets/Colors.xcassets/mijick-text-brand.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xDF", + "green" : "0x90", + "red" : "0x8A" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/Internal/Assets/Colors.xcassets/mijick-text-primary.colorset/Contents.json b/Sources/Internal/Assets/Colors.xcassets/mijick-text-primary.colorset/Contents.json new file mode 100644 index 0000000..27eef21 --- /dev/null +++ b/Sources/Internal/Assets/Colors.xcassets/mijick-text-primary.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xEF", + "green" : "0xEF", + "red" : "0xF1" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/Internal/Assets/Colors.xcassets/mijick-text-secondary.colorset/Contents.json b/Sources/Internal/Assets/Colors.xcassets/mijick-text-secondary.colorset/Contents.json new file mode 100644 index 0000000..da6d981 --- /dev/null +++ b/Sources/Internal/Assets/Colors.xcassets/mijick-text-secondary.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xC8", + "green" : "0xC6", + "red" : "0xC6" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/Internal/Assets/Colors.xcassets/mijick-text-tertiary.colorset/Contents.json b/Sources/Internal/Assets/Colors.xcassets/mijick-text-tertiary.colorset/Contents.json new file mode 100644 index 0000000..fe8eda9 --- /dev/null +++ b/Sources/Internal/Assets/Colors.xcassets/mijick-text-tertiary.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xAB", + "green" : "0xA8", + "red" : "0xA8" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/Internal/Assets/Icons.xcassets/Contents.json b/Sources/Internal/Assets/Icons.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Sources/Internal/Assets/Icons.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/Internal/Assets/Icons.xcassets/mijick-icon-cancel.imageset/Contents.json b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-cancel.imageset/Contents.json new file mode 100644 index 0000000..ab89e39 --- /dev/null +++ b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-cancel.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "mijick-icon-cancel.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Sources/Internal/Assets/Icons.xcassets/mijick-icon-cancel.imageset/mijick-icon-cancel.png b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-cancel.imageset/mijick-icon-cancel.png new file mode 100644 index 0000000..9bc619d Binary files /dev/null and b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-cancel.imageset/mijick-icon-cancel.png differ diff --git a/Sources/Internal/Assets/Icons.xcassets/mijick-icon-change-camera.imageset/Contents.json b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-change-camera.imageset/Contents.json new file mode 100644 index 0000000..46e0f8e --- /dev/null +++ b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-change-camera.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "mijick-icon-change-camera.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Sources/Internal/Assets/Icons.xcassets/mijick-icon-change-camera.imageset/mijick-icon-change-camera.png b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-change-camera.imageset/mijick-icon-change-camera.png new file mode 100644 index 0000000..6424022 Binary files /dev/null and b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-change-camera.imageset/mijick-icon-change-camera.png differ diff --git a/Sources/Internal/Assets/Icons.xcassets/mijick-icon-check.imageset/Contents.json b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-check.imageset/Contents.json new file mode 100644 index 0000000..d7b90e1 --- /dev/null +++ b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-check.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "mijick-icon-check.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Sources/Internal/Assets/Icons.xcassets/mijick-icon-check.imageset/mijick-icon-check.png b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-check.imageset/mijick-icon-check.png new file mode 100644 index 0000000..508b342 Binary files /dev/null and b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-check.imageset/mijick-icon-check.png differ diff --git a/Sources/Internal/Assets/Icons.xcassets/mijick-icon-crosshair.imageset/Contents.json b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-crosshair.imageset/Contents.json new file mode 100644 index 0000000..b6597bd --- /dev/null +++ b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-crosshair.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "mijick-icon-crosshair.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Sources/Internal/Assets/Icons.xcassets/mijick-icon-crosshair.imageset/mijick-icon-crosshair.png b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-crosshair.imageset/mijick-icon-crosshair.png new file mode 100644 index 0000000..cd7abd2 Binary files /dev/null and b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-crosshair.imageset/mijick-icon-crosshair.png differ diff --git a/Sources/Internal/Assets/Icons.xcassets/mijick-icon-flash-auto.imageset/Contents.json b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-flash-auto.imageset/Contents.json new file mode 100644 index 0000000..1445d13 --- /dev/null +++ b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-flash-auto.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "mijick-icon-flash-auto.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Sources/Internal/Assets/Icons.xcassets/mijick-icon-flash-auto.imageset/mijick-icon-flash-auto.png b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-flash-auto.imageset/mijick-icon-flash-auto.png new file mode 100644 index 0000000..1f22293 Binary files /dev/null and b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-flash-auto.imageset/mijick-icon-flash-auto.png differ diff --git a/Sources/Internal/Assets/Icons.xcassets/mijick-icon-flash-off.imageset/Contents.json b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-flash-off.imageset/Contents.json new file mode 100644 index 0000000..de984d2 --- /dev/null +++ b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-flash-off.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "mijick-icon-flash-off.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Sources/Internal/Assets/Icons.xcassets/mijick-icon-flash-off.imageset/mijick-icon-flash-off.png b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-flash-off.imageset/mijick-icon-flash-off.png new file mode 100644 index 0000000..b057ae7 Binary files /dev/null and b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-flash-off.imageset/mijick-icon-flash-off.png differ diff --git a/Sources/Internal/Assets/Icons.xcassets/mijick-icon-flash-on.imageset/Contents.json b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-flash-on.imageset/Contents.json new file mode 100644 index 0000000..c9d708e --- /dev/null +++ b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-flash-on.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "mijick-icon-flash-on.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Sources/Internal/Assets/Icons.xcassets/mijick-icon-flash-on.imageset/mijick-icon-flash-on.png b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-flash-on.imageset/mijick-icon-flash-on.png new file mode 100644 index 0000000..d422202 Binary files /dev/null and b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-flash-on.imageset/mijick-icon-flash-on.png differ diff --git a/Sources/Internal/Assets/Icons.xcassets/mijick-icon-flip-off.imageset/Contents.json b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-flip-off.imageset/Contents.json new file mode 100644 index 0000000..58b5bf9 --- /dev/null +++ b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-flip-off.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "mijick-icon-flip-off.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Sources/Internal/Assets/Icons.xcassets/mijick-icon-flip-off.imageset/mijick-icon-flip-off.png b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-flip-off.imageset/mijick-icon-flip-off.png new file mode 100644 index 0000000..9d039a3 Binary files /dev/null and b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-flip-off.imageset/mijick-icon-flip-off.png differ diff --git a/Sources/Internal/Assets/Icons.xcassets/mijick-icon-flip-on.imageset/Contents.json b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-flip-on.imageset/Contents.json new file mode 100644 index 0000000..5584a14 --- /dev/null +++ b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-flip-on.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "mijick-icon-flip-on.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Sources/Internal/Assets/Icons.xcassets/mijick-icon-flip-on.imageset/mijick-icon-flip-on.png b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-flip-on.imageset/mijick-icon-flip-on.png new file mode 100644 index 0000000..0cf66e6 Binary files /dev/null and b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-flip-on.imageset/mijick-icon-flip-on.png differ diff --git a/Sources/Internal/Assets/Icons.xcassets/mijick-icon-grid-off.imageset/Contents.json b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-grid-off.imageset/Contents.json new file mode 100644 index 0000000..317bfed --- /dev/null +++ b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-grid-off.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "mijick-icon-grid-off.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Sources/Internal/Assets/Icons.xcassets/mijick-icon-grid-off.imageset/mijick-icon-grid-off.png b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-grid-off.imageset/mijick-icon-grid-off.png new file mode 100644 index 0000000..2942307 Binary files /dev/null and b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-grid-off.imageset/mijick-icon-grid-off.png differ diff --git a/Sources/Internal/Assets/Icons.xcassets/mijick-icon-grid-on.imageset/Contents.json b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-grid-on.imageset/Contents.json new file mode 100644 index 0000000..5d8b0cc --- /dev/null +++ b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-grid-on.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "mijick-icon-grid-on.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Sources/Internal/Assets/Icons.xcassets/mijick-icon-grid-on.imageset/mijick-icon-grid-on.png b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-grid-on.imageset/mijick-icon-grid-on.png new file mode 100644 index 0000000..fe64850 Binary files /dev/null and b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-grid-on.imageset/mijick-icon-grid-on.png differ diff --git a/Sources/Internal/Assets/Icons.xcassets/mijick-icon-light.imageset/Contents.json b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-light.imageset/Contents.json new file mode 100644 index 0000000..d552632 --- /dev/null +++ b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-light.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "mijick-icon-light.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Sources/Internal/Assets/Icons.xcassets/mijick-icon-light.imageset/mijick-icon-light.png b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-light.imageset/mijick-icon-light.png new file mode 100644 index 0000000..f57e973 Binary files /dev/null and b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-light.imageset/mijick-icon-light.png differ diff --git a/Sources/Internal/Assets/Icons.xcassets/mijick-icon-photo.imageset/Contents.json b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-photo.imageset/Contents.json new file mode 100644 index 0000000..fb5b643 --- /dev/null +++ b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-photo.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "mijick-icon-photo.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Sources/Internal/Assets/Icons.xcassets/mijick-icon-photo.imageset/mijick-icon-photo.png b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-photo.imageset/mijick-icon-photo.png new file mode 100644 index 0000000..fe98320 Binary files /dev/null and b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-photo.imageset/mijick-icon-photo.png differ diff --git a/Sources/Internal/Assets/Icons.xcassets/mijick-icon-video.imageset/Contents.json b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-video.imageset/Contents.json new file mode 100644 index 0000000..6ceb034 --- /dev/null +++ b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-video.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "mijick-icon-video.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Sources/Internal/Assets/Icons.xcassets/mijick-icon-video.imageset/mijick-icon-video.png b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-video.imageset/mijick-icon-video.png new file mode 100644 index 0000000..78c892f Binary files /dev/null and b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-video.imageset/mijick-icon-video.png differ diff --git a/Sources/Internal/Extensions/AVCaptureVideoOrientation++.swift b/Sources/Internal/Extensions/AVCaptureVideoOrientation++.swift new file mode 100644 index 0000000..bd6d68a --- /dev/null +++ b/Sources/Internal/Extensions/AVCaptureVideoOrientation++.swift @@ -0,0 +1,46 @@ +// +// AVCaptureVideoOrientation++.swift of MijickCamera +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import SwiftUI +import AVKit + +// MARK: To Angle +extension AVCaptureVideoOrientation { + func getAngle() -> Angle { switch self { + case .portrait: .degrees(0) + case .landscapeLeft: .degrees(-90) + case .landscapeRight: .degrees(90) + case .portraitUpsideDown: .degrees(180) + default: .degrees(0) + }} +} + +// MARK: To UIImageOrientation +extension AVCaptureVideoOrientation { + func toImageOrientation() -> UIImage.Orientation { switch self { + case .portrait: .downMirrored + case .landscapeLeft: .leftMirrored + case .landscapeRight: .rightMirrored + case .portraitUpsideDown: .upMirrored + default: .up + }} +} + +// MARK: To UIDeviceOrientation +extension AVCaptureVideoOrientation { + func toDeviceOrientation() -> UIDeviceOrientation { switch self { + case .portrait: .portrait + case .portraitUpsideDown: .portraitUpsideDown + case .landscapeLeft: .landscapeLeft + case .landscapeRight: .landscapeRight + default: .portrait + }} +} diff --git a/Sources/Internal/Extensions/AVVideoComposition++.swift b/Sources/Internal/Extensions/AVVideoComposition++.swift new file mode 100644 index 0000000..e88a654 --- /dev/null +++ b/Sources/Internal/Extensions/AVVideoComposition++.swift @@ -0,0 +1,20 @@ +// +// AVVideoComposition++.swift of MijickCamera +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import AVKit + +// MARK: Apply Filters +extension AVVideoComposition { + static func applyFilters(to asset: AVAsset, applyFiltersAction: @Sendable @escaping (AVAsynchronousCIImageFilteringRequest) -> ()) async throws -> AVVideoComposition { + if #available(iOS 16.0, *) { return try await AVVideoComposition.videoComposition(with: asset, applyingCIFiltersWithHandler: applyFiltersAction) } + return AVVideoComposition(asset: asset, applyingCIFiltersWithHandler: applyFiltersAction) + } +} diff --git a/Sources/Internal/Extensions/Animation++.swift b/Sources/Internal/Extensions/Animation++.swift new file mode 100644 index 0000000..5fbf9da --- /dev/null +++ b/Sources/Internal/Extensions/Animation++.swift @@ -0,0 +1,20 @@ +// +// Animation++.swift of MijickCamera +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import SwiftUI + +// MARK: Custom Animation +extension Animation { + static var mSpring: Animation { .spring(duration: duration, bounce: 0, blendDuration: 0) } +} +extension Animation { + static var duration: CGFloat { 0.3 } +} diff --git a/Sources/Internal/Extensions/CIFilter++.swift b/Sources/Internal/Extensions/CIFilter++.swift new file mode 100644 index 0000000..c1ff1ea --- /dev/null +++ b/Sources/Internal/Extensions/CIFilter++.swift @@ -0,0 +1,14 @@ +// +// CIFilter++.swift of MijickCamera +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import AVKit + +extension CIFilter: @unchecked @retroactive Sendable {} diff --git a/Sources/Internal/Extensions/CIImage++.swift b/Sources/Internal/Extensions/CIImage++.swift new file mode 100644 index 0000000..0ded9bb --- /dev/null +++ b/Sources/Internal/Extensions/CIImage++.swift @@ -0,0 +1,24 @@ +// +// CIImage++.swift of MijickCamera +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import SwiftUI + +// MARK: Applying Filters +extension CIImage { + func applyingFilters(_ filters: [CIFilter]) -> CIImage { + var ciImage = self + filters.forEach { + $0.setValue(ciImage, forKey: kCIInputImageKey) + ciImage = $0.outputImage ?? ciImage + } + return ciImage + } +} diff --git a/Sources/Internal/Extensions/CameraUtilities++.swift b/Sources/Internal/Extensions/CameraUtilities++.swift new file mode 100644 index 0000000..370a94f --- /dev/null +++ b/Sources/Internal/Extensions/CameraUtilities++.swift @@ -0,0 +1,21 @@ +// +// CameraUtilities++.swift of MijickCamera +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import AVKit + +// MARK: To Device Flash Mode +extension CameraFlashMode { + func toDeviceFlashMode() -> AVCaptureDevice.FlashMode { switch self { + case .off: .off + case .on: .on + case .auto: .auto + }} +} diff --git a/Sources/Internal/Extensions/CaseIterable++.swift b/Sources/Internal/Extensions/CaseIterable++.swift new file mode 100644 index 0000000..30dd6bc --- /dev/null +++ b/Sources/Internal/Extensions/CaseIterable++.swift @@ -0,0 +1,22 @@ +// +// CaseIterable++.swift of MijickCamera +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import Foundation + +// MARK: Next +extension CaseIterable where Self: Equatable { + func next() -> Self { + guard let index = Self.allCases.firstIndex(of: self) else { return self } + + let nextIndex = Self.allCases.index(after: index) + return Self.allCases[nextIndex == Self.allCases.endIndex ? Self.allCases.startIndex : nextIndex] + } +} diff --git a/Sources/Internal/Extensions/FileManager++.swift b/Sources/Internal/Extensions/FileManager++.swift new file mode 100644 index 0000000..50db55e --- /dev/null +++ b/Sources/Internal/Extensions/FileManager++.swift @@ -0,0 +1,35 @@ +// +// FileManager++.swift of MijickCamera +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import SwiftUI + +// MARK: Prepare Place for Video Output +extension FileManager { + static func prepareURLForVideoOutput() -> URL? { + guard let fileUrl = getFileUrl() else { return nil } + + clearPlaceIfTaken(fileUrl) + return fileUrl + } +} +private extension FileManager { + static func getFileUrl() -> URL? { + FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) + .first? + .appendingPathComponent(videoPath) + } + static func clearPlaceIfTaken(_ url: URL) { + try? FileManager.default.removeItem(at: url) + } +} +private extension FileManager { + static var videoPath: String { "mijick-camera-video-output.mp4" } +} diff --git a/Sources/Internal/Extensions/Task++.swift b/Sources/Internal/Extensions/Task++.swift new file mode 100644 index 0000000..cffd1ef --- /dev/null +++ b/Sources/Internal/Extensions/Task++.swift @@ -0,0 +1,19 @@ +// +// Task++.swift of MijickCamera +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import Foundation + +// MARK: Sleep +extension Task where Success == Never, Failure == Never { + static func sleep(seconds: CGFloat) async { + try! await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + } +} diff --git a/Sources/Internal/Extensions/UIImage.Orientation++.swift b/Sources/Internal/Extensions/UIImage.Orientation++.swift new file mode 100644 index 0000000..77b91c5 --- /dev/null +++ b/Sources/Internal/Extensions/UIImage.Orientation++.swift @@ -0,0 +1,26 @@ +// +// UIImage.Orientation++.swift of MijickCamera +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import SwiftUI + +// MARK: From CGImagePropertyOrientation +extension UIImage.Orientation { + init(_ orientation: CGImagePropertyOrientation) { switch orientation { + case .down: self = .down + case .downMirrored: self = .downMirrored + case .left: self = .left + case .leftMirrored: self = .leftMirrored + case .right: self = .right + case .rightMirrored: self = .rightMirrored + case .up: self = .up + case .upMirrored: self = .upMirrored + }} +} diff --git a/Sources/Internal/Extensions/UIView++.swift b/Sources/Internal/Extensions/UIView++.swift new file mode 100644 index 0000000..735c972 --- /dev/null +++ b/Sources/Internal/Extensions/UIView++.swift @@ -0,0 +1,43 @@ +// +// UIView++.swift of MijickCamera +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import SwiftUI + +// MARK: Add to Parent +extension UIView { + func addToParent(_ view: UIView) { + view.addSubview(self) + + translatesAutoresizingMaskIntoConstraints = false + leftAnchor.constraint(equalTo: view.leftAnchor, constant: 0).isActive = true + rightAnchor.constraint(equalTo: view.rightAnchor, constant: 0).isActive = true + topAnchor.constraint(equalTo: view.topAnchor, constant: 0).isActive = true + bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: 0).isActive = true + } +} + +// MARK: Apply Blur Effect +extension UIView { + func applyBlurEffect(style: UIBlurEffect.Style) { + let blurEffectView = UIVisualEffectView() + blurEffectView.frame = bounds + blurEffectView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + blurEffectView.effect = UIBlurEffect(style: style) + + addSubview(blurEffectView) + } +} + +// MARK: Tags +extension Int { + static var blurViewTag: Int { 2137 } + static var focusIndicatorTag: Int { 29 } +} diff --git a/Sources/Internal/Extensions/View++.swift b/Sources/Internal/Extensions/View++.swift new file mode 100644 index 0000000..7420855 --- /dev/null +++ b/Sources/Internal/Extensions/View++.swift @@ -0,0 +1,17 @@ +// +// View++.swift of MijickCamera +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import SwiftUI + +// MARK: Erased +extension View { + func erased() -> AnyView { .init(self) } +} diff --git a/Sources/Internal/Manager/CameraManager+Attributes.swift b/Sources/Internal/Manager/CameraManager+Attributes.swift new file mode 100644 index 0000000..fd3aa65 --- /dev/null +++ b/Sources/Internal/Manager/CameraManager+Attributes.swift @@ -0,0 +1,40 @@ +// +// CameraManager+Attributes.swift of MijickCamera +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import AVKit +import UIKit + +struct CameraManagerAttributes { + var capturedMedia: MCameraMedia? = nil + var error: MCameraError? = nil + + var outputType: CameraOutputType = .photo + var cameraPosition: CameraPosition = .back + var isAudioSourceAvailable: Bool = true + var zoomFactor: CGFloat = 1.0 + var flashMode: CameraFlashMode = .off + var lightMode: CameraLightMode = .off + var resolution: AVCaptureSession.Preset = .hd1920x1080 + var frameRate: Int32 = 30 + var cameraExposure: CameraExposure = .init() + var hdrMode: CameraHDRMode = .auto + var cameraFilters: [CIFilter] = [] + var mirrorOutput: Bool = false + var isGridVisible: Bool = true + + /// Color for screen flash on front camera (nil = use default white) + var screenFlashColor: UIColor? = nil + + var deviceOrientation: AVCaptureVideoOrientation = .portrait + var frameOrientation: CGImagePropertyOrientation = .right + var orientationLocked: Bool = false + var userBlockedScreenRotation: Bool = false +} diff --git a/Sources/Internal/Manager/CameraManager+MotionManager.swift b/Sources/Internal/Manager/CameraManager+MotionManager.swift new file mode 100644 index 0000000..a1faaf9 --- /dev/null +++ b/Sources/Internal/Manager/CameraManager+MotionManager.swift @@ -0,0 +1,108 @@ +// +// CameraManager+MotionManager.swift of MijickCamera +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import CoreMotion +import AVKit + +@MainActor class CameraManagerMotionManager { + private(set) var parent: CameraManager! + private(set) var manager: CMMotionManager = .init() +} + +// MARK: Setup +extension CameraManagerMotionManager { + func setup(parent: CameraManager) { + self.parent = parent + manager.accelerometerUpdateInterval = 0.05 + manager.startAccelerometerUpdates(to: .current ?? .init(), withHandler: handleAccelerometerUpdates) + } +} +private extension CameraManagerMotionManager { + func handleAccelerometerUpdates(_ data: CMAccelerometerData?, _ error: Error?) { + guard let data, error == nil else { return } + + let newDeviceOrientation = getDeviceOrientation(data.acceleration) + updateDeviceOrientation(newDeviceOrientation) + updateUserBlockedScreenRotation() + updateFrameOrientation() + redrawGrid() + } +} +private extension CameraManagerMotionManager { + func getDeviceOrientation(_ acceleration: CMAcceleration) -> AVCaptureVideoOrientation { switch acceleration { + case let acceleration where acceleration.x >= 0.75: .landscapeLeft + case let acceleration where acceleration.x <= -0.75: .landscapeRight + case let acceleration where acceleration.y <= -0.75: .portrait + case let acceleration where acceleration.y >= 0.75: .portraitUpsideDown + default: parent.attributes.deviceOrientation + }} + func updateDeviceOrientation(_ newDeviceOrientation: AVCaptureVideoOrientation) { if newDeviceOrientation != parent.attributes.deviceOrientation { + parent.attributes.deviceOrientation = newDeviceOrientation + }} + func updateUserBlockedScreenRotation() { + let newUserBlockedScreenRotation = getNewUserBlockedScreenRotation() + if newUserBlockedScreenRotation != parent.attributes.userBlockedScreenRotation { parent.attributes.userBlockedScreenRotation = newUserBlockedScreenRotation } + } + func updateFrameOrientation() { if UIDevice.current.orientation != .portraitUpsideDown { + let newFrameOrientation = getNewFrameOrientation(parent.attributes.orientationLocked ? .portrait : UIDevice.current.orientation) + updateFrameOrientation(newFrameOrientation) + }} + func redrawGrid() { if !parent.attributes.orientationLocked { + parent.cameraGridView.draw(.zero) + }} +} +private extension CameraManagerMotionManager { + func getNewUserBlockedScreenRotation() -> Bool { switch parent.attributes.deviceOrientation.rawValue == UIDevice.current.orientation.rawValue { + case true: false + case false: !parent.attributes.orientationLocked + }} + func getNewFrameOrientation(_ orientation: UIDeviceOrientation) -> CGImagePropertyOrientation { switch parent.attributes.cameraPosition { + case .back: getNewFrameOrientationForBackCamera(orientation) + case .front: getNewFrameOrientationForFrontCamera(orientation) + }} + func updateFrameOrientation(_ newFrameOrientation: CGImagePropertyOrientation) { if newFrameOrientation != parent.attributes.frameOrientation { + let shouldAnimate = shouldAnimateFrameOrientationChange(newFrameOrientation) + updateFrameOrientation(withAnimation: shouldAnimate, newFrameOrientation: newFrameOrientation) + }} +} +private extension CameraManagerMotionManager { + func getNewFrameOrientationForBackCamera(_ orientation: UIDeviceOrientation) -> CGImagePropertyOrientation { switch orientation { + case .portrait: parent.attributes.mirrorOutput ? .leftMirrored : .right + case .landscapeLeft: parent.attributes.mirrorOutput ? .upMirrored : .up + case .landscapeRight: parent.attributes.mirrorOutput ? .downMirrored : .down + default: parent.attributes.mirrorOutput ? .leftMirrored : .right + }} + func getNewFrameOrientationForFrontCamera(_ orientation: UIDeviceOrientation) -> CGImagePropertyOrientation { switch orientation { + case .portrait: parent.attributes.mirrorOutput ? .right : .leftMirrored + case .landscapeLeft: parent.attributes.mirrorOutput ? .down : .downMirrored + case .landscapeRight: parent.attributes.mirrorOutput ? .up : .upMirrored + default: parent.attributes.mirrorOutput ? .right : .leftMirrored + }} + func shouldAnimateFrameOrientationChange(_ newFrameOrientation: CGImagePropertyOrientation) -> Bool { + let backCameraOrientations: [CGImagePropertyOrientation] = [.left, .right, .up, .down], + frontCameraOrientations: [CGImagePropertyOrientation] = [.leftMirrored, .rightMirrored, .upMirrored, .downMirrored] + + return (backCameraOrientations.contains(newFrameOrientation) && backCameraOrientations.contains(parent.attributes.frameOrientation)) || + (frontCameraOrientations.contains(parent.attributes.frameOrientation) && frontCameraOrientations.contains(newFrameOrientation)) + } + func updateFrameOrientation(withAnimation shouldAnimate: Bool, newFrameOrientation: CGImagePropertyOrientation) { Task { + await parent.cameraMetalView.beginCameraOrientationAnimation(if: shouldAnimate) + parent.attributes.frameOrientation = newFrameOrientation + parent.cameraMetalView.finishCameraOrientationAnimation(if: shouldAnimate) + }} +} + +// MARK: Reset +extension CameraManagerMotionManager { + func reset() { + manager.stopAccelerometerUpdates() + } +} diff --git a/Sources/Internal/Manager/CameraManager+NotificationCenter.swift b/Sources/Internal/Manager/CameraManager+NotificationCenter.swift new file mode 100644 index 0000000..4f9aebe --- /dev/null +++ b/Sources/Internal/Manager/CameraManager+NotificationCenter.swift @@ -0,0 +1,37 @@ +// +// CameraManager+NotificationCenter.swift of MijickCamera +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import Foundation + +@MainActor class CameraManagerNotificationCenter { + private(set) var parent: CameraManager! +} + +// MARK: Setup +extension CameraManagerNotificationCenter { + func setup(parent: CameraManager) { + self.parent = parent + NotificationCenter.default.addObserver(self, selector: #selector(handleSessionWasInterrupted), name: .AVCaptureSessionWasInterrupted, object: parent.captureSession) + } +} +private extension CameraManagerNotificationCenter { + @objc func handleSessionWasInterrupted() { + parent.attributes.lightMode = .off + parent.videoOutput.reset() + } +} + +// MARK: Reset +extension CameraManagerNotificationCenter { + func reset() { + NotificationCenter.default.removeObserver(self, name: .AVCaptureSessionWasInterrupted, object: parent?.captureSession) + } +} diff --git a/Sources/Internal/Manager/CameraManager+PermissionsManager.swift b/Sources/Internal/Manager/CameraManager+PermissionsManager.swift new file mode 100644 index 0000000..c292752 --- /dev/null +++ b/Sources/Internal/Manager/CameraManager+PermissionsManager.swift @@ -0,0 +1,46 @@ +// +// CameraManager+PermissionsManager.swift of MijickCamera +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import AVKit + +@MainActor class CameraManagerPermissionsManager {} + +// MARK: Request Access +extension CameraManagerPermissionsManager { + func requestAccess(parent: CameraManager) async throws(MCameraError) { + do { + try await getAuthorizationStatus(for: .video) + if parent.attributes.isAudioSourceAvailable { try await getAuthorizationStatus(for: .audio) } + } + catch { + parent.attributes.error = error + throw error + } + } +} +private extension CameraManagerPermissionsManager { + func getAuthorizationStatus(for mediaType: AVMediaType) async throws(MCameraError) { switch AVCaptureDevice.authorizationStatus(for: mediaType) { + case .denied, .restricted: throw getPermissionsError(mediaType) + case .notDetermined: try await requestAccess(for: mediaType) + default: return + }} +} +private extension CameraManagerPermissionsManager { + func requestAccess(for mediaType: AVMediaType) async throws(MCameraError) { + let isGranted = await AVCaptureDevice.requestAccess(for: mediaType) + if !isGranted { throw getPermissionsError(mediaType) } + } + func getPermissionsError(_ mediaType: AVMediaType) -> MCameraError { switch mediaType { + case .audio: .microphonePermissionsNotGranted + case .video: .cameraPermissionsNotGranted + default: fatalError() + }} +} diff --git a/Sources/Internal/Manager/CameraManager+PhotoOutput.swift b/Sources/Internal/Manager/CameraManager+PhotoOutput.swift new file mode 100644 index 0000000..9d20346 --- /dev/null +++ b/Sources/Internal/Manager/CameraManager+PhotoOutput.swift @@ -0,0 +1,127 @@ +// +// CameraManager+PhotoOutput.swift of MijickCamera +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import AVKit + +@MainActor class CameraManagerPhotoOutput: NSObject { + private(set) var parent: CameraManager! + private(set) var output: AVCapturePhotoOutput = .init() +} + +// MARK: Setup +extension CameraManagerPhotoOutput { + func setup(parent: CameraManager) throws(MCameraError) { + self.parent = parent + try self.parent.captureSession.add(output: output) + } +} + + +// MARK: - CAPTURE PHOTO + + + +// MARK: Capture +extension CameraManagerPhotoOutput { + func capture() { + guard let parent else { + print("CameraManagerPhotoOutput: parent is nil, cannot capture") + return + } + + configureOutput() + + // Check if we should disable iOS Retina Flash (when using custom screen flash from SwiftUI layer) + let disableBuiltInFlash = parent.attributes.screenFlashColor != nil && + parent.attributes.cameraPosition == .front && + parent.attributes.flashMode != .off + + let settings = getPhotoOutputSettings(disableFlash: disableBuiltInFlash) + output.capturePhoto(with: settings, delegate: self) + parent.cameraMetalView.performImageCaptureAnimation() + } +} +private extension CameraManagerPhotoOutput { + func getPhotoOutputSettings(disableFlash: Bool) -> AVCapturePhotoSettings { + let settings = AVCapturePhotoSettings() + + // When using custom screen flash for front camera, disable iOS's built-in flash + // to prevent double-flash (our custom flash + iOS Retina Flash) + if disableFlash { + settings.flashMode = .off + } else { + // For back camera, use the requested flash mode if supported + let desiredFlashMode = parent.attributes.flashMode.toDeviceFlashMode() + if output.supportedFlashModes.contains(desiredFlashMode) { + settings.flashMode = desiredFlashMode + } else { + settings.flashMode = .off + } + } + + return settings + } + func configureOutput() { + guard let connection = output.connection(with: .video), connection.isVideoMirroringSupported else { return } + + connection.isVideoMirrored = parent.attributes.mirrorOutput ? parent.attributes.cameraPosition != .front : parent.attributes.cameraPosition == .front + connection.videoOrientation = parent.attributes.deviceOrientation + } +} + +// MARK: Receive Data +extension CameraManagerPhotoOutput: @preconcurrency AVCapturePhotoCaptureDelegate { + func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: (any Error)?) { + guard let imageData = photo.fileDataRepresentation(), + let ciImage = CIImage(data: imageData) + else { return } + + let capturedCIImage = prepareCIImage(ciImage, parent.attributes.cameraFilters) + let capturedCGImage = prepareCGImage(capturedCIImage) + let capturedUIImage = prepareUIImage(capturedCGImage) + let capturedMedia = MCameraMedia(data: capturedUIImage) + + parent.setCapturedMedia(capturedMedia) + } +} +private extension CameraManagerPhotoOutput { + func prepareCIImage(_ ciImage: CIImage, _ filters: [CIFilter]) -> CIImage { + ciImage.applyingFilters(filters) + } + func prepareCGImage(_ ciImage: CIImage) -> CGImage? { + CIContext().createCGImage(ciImage, from: ciImage.extent) + } + func prepareUIImage(_ cgImage: CGImage?) -> UIImage? { + guard let cgImage else { return nil } + + let frameOrientation = getFixedFrameOrientation() + let orientation = UIImage.Orientation(frameOrientation) + let uiImage = UIImage(cgImage: cgImage, scale: 1.0, orientation: orientation) + return uiImage + } +} +private extension CameraManagerPhotoOutput { + func getFixedFrameOrientation() -> CGImagePropertyOrientation { + guard UIDevice.current.orientation != parent.attributes.deviceOrientation.toDeviceOrientation() else { return parent.attributes.frameOrientation } + + return switch (parent.attributes.deviceOrientation, parent.attributes.cameraPosition) { + case (.portrait, .front): .left + case (.portrait, .back): .right + case (.landscapeLeft, .back): .down + case (.landscapeRight, .back): .up + case (.landscapeLeft, .front) where parent.attributes.mirrorOutput: .up + case (.landscapeLeft, .front): .upMirrored + case (.landscapeRight, .front) where parent.attributes.mirrorOutput: .down + case (.landscapeRight, .front): .downMirrored + default: .right + } + } +} diff --git a/Sources/Internal/Manager/CameraManager+VideoOutput.swift b/Sources/Internal/Manager/CameraManager+VideoOutput.swift new file mode 100644 index 0000000..b5cb37a --- /dev/null +++ b/Sources/Internal/Manager/CameraManager+VideoOutput.swift @@ -0,0 +1,158 @@ +// +// CameraManager+VideoOutput.swift of MijickCamera +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +@preconcurrency import AVKit +import SwiftUI +import MijickTimer + +@MainActor class CameraManagerVideoOutput: NSObject { + private(set) var parent: CameraManager! + private(set) var output: AVCaptureMovieFileOutput = .init() + private(set) var timer: MTimer = .init(.camera) + private(set) var recordingTime: MTime = .zero + private(set) var firstRecordedFrame: UIImage? +} + +// MARK: Setup +extension CameraManagerVideoOutput { + func setup(parent: CameraManager) throws(MCameraError) { + self.parent = parent + try parent.captureSession.add(output: output) + } +} + +// MARK: Reset +extension CameraManagerVideoOutput { + func reset() { + timer.reset() + } +} + + +// MARK: - CAPTURE VIDEO + + + +// MARK: Toggle +extension CameraManagerVideoOutput { + func toggleRecording() { switch output.isRecording { + case true: stopRecording() + case false: startRecording() + }} +} + +// MARK: Start Recording +private extension CameraManagerVideoOutput { + func startRecording() { + guard let url = prepareUrlForVideoRecording() else { return } + + configureOutput() + storeLastFrame() + output.startRecording(to: url, recordingDelegate: self) + startRecordingTimer() + parent.objectWillChange.send() + } +} +private extension CameraManagerVideoOutput { + func prepareUrlForVideoRecording() -> URL? { + FileManager.prepareURLForVideoOutput() + } + func configureOutput() { + guard let connection = output.connection(with: .video), connection.isVideoMirroringSupported else { return } + + connection.isVideoMirrored = parent.attributes.mirrorOutput ? parent.attributes.cameraPosition != .front : parent.attributes.cameraPosition == .front + connection.videoOrientation = parent.attributes.deviceOrientation + } + func storeLastFrame() { + guard let texture = parent.cameraMetalView.currentDrawable?.texture, + let ciImage = CIImage(mtlTexture: texture, options: nil), + let cgImage = parent.cameraMetalView.ciContext.createCGImage(ciImage, from: ciImage.extent) + else { return } + + firstRecordedFrame = UIImage(cgImage: cgImage, scale: 1.0, orientation: parent.attributes.deviceOrientation.toImageOrientation()) + } + func startRecordingTimer() { try? timer + .publish(every: 1) { [self] in + recordingTime = $0 + parent.objectWillChange.send() + } + .start() + } +} + +// MARK: Stop Recording +private extension CameraManagerVideoOutput { + func stopRecording() { + presentLastFrame() + output.stopRecording() + timer.reset() + } +} +private extension CameraManagerVideoOutput { + func presentLastFrame() { + let firstRecordedFrame = MCameraMedia(data: firstRecordedFrame) + parent.setCapturedMedia(firstRecordedFrame) + } +} + +// MARK: Receive Data +extension CameraManagerVideoOutput: @preconcurrency AVCaptureFileOutputRecordingDelegate { + func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: (any Error)?) { Task { + let videoURL = try await prepareVideo(outputFileURL: outputFileURL, cameraFilters: parent.attributes.cameraFilters) + let capturedVideo = MCameraMedia(data: videoURL) + + await Task.sleep(seconds: Animation.duration) + parent.setCapturedMedia(capturedVideo) + }} +} +private extension CameraManagerVideoOutput { + func prepareVideo(outputFileURL: URL, cameraFilters: [CIFilter]) async throws -> URL { + if cameraFilters.isEmpty { return outputFileURL } + + let asset = AVAsset(url: outputFileURL) + let videoComposition = try await AVVideoComposition.applyFilters(to: asset) { self.applyFiltersToVideo($0, cameraFilters) } + let fileUrl = FileManager.prepareURLForVideoOutput() + let exportSession = prepareAssetExportSession(asset, fileUrl, videoComposition) + + try await exportVideo(exportSession, fileUrl) + return fileUrl ?? outputFileURL + } +} +private extension CameraManagerVideoOutput { + nonisolated func applyFiltersToVideo(_ request: AVAsynchronousCIImageFilteringRequest, _ filters: [CIFilter]) { + let videoFrame = prepareVideoFrame(request, filters) + request.finish(with: videoFrame, context: nil) + } + nonisolated func exportVideo(_ exportSession: AVAssetExportSession?, _ fileUrl: URL?) async throws { if let fileUrl { + if #available(iOS 18, *) { try await exportSession?.export(to: fileUrl, as: .mov) } + else { await exportSession?.export() } + }} +} +private extension CameraManagerVideoOutput { + nonisolated func prepareVideoFrame(_ request: AVAsynchronousCIImageFilteringRequest, _ filters: [CIFilter]) -> CIImage { request + .sourceImage + .clampedToExtent() + .applyingFilters(filters) + } + nonisolated func prepareAssetExportSession(_ asset: AVAsset, _ fileUrl: URL?, _ composition: AVVideoComposition?) -> AVAssetExportSession? { + let export = AVAssetExportSession(asset: asset, presetName: AVAssetExportPreset1920x1080) + export?.outputFileType = .mov + export?.outputURL = fileUrl + export?.videoComposition = composition + return export + } +} + + +// MARK: - HELPERS +fileprivate extension MTimerID { + static let camera: MTimerID = .init(rawValue: "mijick-camera") +} diff --git a/Sources/Internal/Manager/CameraManager.swift b/Sources/Internal/Manager/CameraManager.swift new file mode 100644 index 0000000..36ba37f --- /dev/null +++ b/Sources/Internal/Manager/CameraManager.swift @@ -0,0 +1,438 @@ +// +// CameraManager.swift of MijickCamera +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import SwiftUI +import AVKit + +@MainActor public class CameraManager: NSObject, ObservableObject { + @Published var attributes: CameraManagerAttributes = .init() + + // MARK: Input + private(set) var captureSession: any CaptureSession + private(set) var frontCameraInput: (any CaptureDeviceInput)? + private(set) var backCameraInput: (any CaptureDeviceInput)? + + // MARK: Output + private(set) var photoOutput: CameraManagerPhotoOutput = .init() + private(set) var videoOutput: CameraManagerVideoOutput = .init() + + // MARK: UI Elements + private(set) var cameraView: UIView! + private(set) var cameraLayer: AVCaptureVideoPreviewLayer = .init() + private(set) var cameraMetalView: CameraMetalView = .init() + private(set) var cameraGridView: CameraGridView = .init() + + // MARK: Others + private(set) var permissionsManager: CameraManagerPermissionsManager = .init() + private(set) var motionManager: CameraManagerMotionManager = .init() + private(set) var notificationCenterManager: CameraManagerNotificationCenter = .init() + + // MARK: Initializer + init(captureSession: CS, captureDeviceInputType: CDI.Type) { + self.captureSession = captureSession + self.frontCameraInput = CDI.get(mediaType: .video, position: .front) + self.backCameraInput = CDI.get(mediaType: .video, position: .back) + } +} + +// MARK: Initialize +extension CameraManager { + func initialize(in view: UIView) { + cameraView = view + } +} + +// MARK: Setup +extension CameraManager { + func setup() async throws(MCameraError) { + try await permissionsManager.requestAccess(parent: self) + + setupCameraLayer() + try setupDeviceInputs() + try setupDeviceOutput() + try setupFrameRecorder() + notificationCenterManager.setup(parent: self) + motionManager.setup(parent: self) + try cameraMetalView.setup(parent: self) + cameraGridView.setup(parent: self) + + startSession() + } +} +private extension CameraManager { + func setupCameraLayer() { + captureSession.sessionPreset = attributes.resolution + + cameraLayer.session = captureSession as? AVCaptureSession + cameraLayer.videoGravity = .resizeAspectFill + cameraLayer.isHidden = true + cameraView.layer.addSublayer(cameraLayer) + } + func setupDeviceInputs() throws(MCameraError) { + try captureSession.add(input: getCameraInput()) + if let audioInput = getAudioInput() { try captureSession.add(input: audioInput) } + } + func setupDeviceOutput() throws(MCameraError) { + try photoOutput.setup(parent: self) + try videoOutput.setup(parent: self) + } + func setupFrameRecorder() throws(MCameraError) { + let captureVideoOutput = AVCaptureVideoDataOutput() + captureVideoOutput.setSampleBufferDelegate(cameraMetalView, queue: .main) + + try captureSession.add(output: captureVideoOutput) + } + func startSession() { Task { + guard let device = getCameraInput()?.device else { return } + + try await startCaptureSession() + try setupDevice(device) + resetAttributes(device: device) + cameraMetalView.performCameraEntranceAnimation() + }} +} +private extension CameraManager { + func getAudioInput() -> (any CaptureDeviceInput)? { + guard attributes.isAudioSourceAvailable, + let deviceInput = frontCameraInput ?? backCameraInput + else { return nil } + + let captureDeviceInputType = type(of: deviceInput) + let audioInput = captureDeviceInputType.get(mediaType: .audio, position: .unspecified) + return audioInput + } + nonisolated func startCaptureSession() async throws { + await captureSession.startRunning() + } + func setupDevice(_ device: any CaptureDevice) throws { + try device.lockForConfiguration() + device.setExposureMode(attributes.cameraExposure.mode, duration: attributes.cameraExposure.duration, iso: attributes.cameraExposure.iso) + device.setExposureTargetBias(attributes.cameraExposure.targetBias) + device.setFrameRate(attributes.frameRate) + device.setZoomFactor(attributes.zoomFactor) + device.setLightMode(attributes.lightMode) + device.hdrMode = attributes.hdrMode + device.unlockForConfiguration() + } +} + +// MARK: Cancel +extension CameraManager { + func cancel() { + captureSession = captureSession.stopRunningAndReturnNewInstance() + motionManager.reset() + videoOutput.reset() + notificationCenterManager.reset() + } +} + + +// MARK: - LIVE ACTIONS + + + +// MARK: Capture Output +extension CameraManager { + func captureOutput() { + guard !isChanging else { return } + + switch attributes.outputType { + case .photo: photoOutput.capture() + case .video: videoOutput.toggleRecording() + } + } +} + +// MARK: Set Captured Media +public extension CameraManager { + func setCapturedMedia(_ capturedMedia: MCameraMedia?) { withAnimation(.mSpring) { + attributes.capturedMedia = capturedMedia + }} + + var capturedMedia: MCameraMedia? { + attributes.capturedMedia + } +} + +// MARK: Set Camera Output +extension CameraManager { + func setOutputType(_ outputType: CameraOutputType) { + guard outputType != attributes.outputType, !isChanging else { return } + attributes.outputType = outputType + } +} + +// MARK: Set Camera Position +extension CameraManager { + func setCameraPosition(_ position: CameraPosition) async throws { + guard position != attributes.cameraPosition, !isChanging else { return } + + await cameraMetalView.beginCameraFlipAnimation() + try changeCameraInput(position) + resetAttributesWhenChangingCamera(position) + await cameraMetalView.finishCameraFlipAnimation() + } +} +private extension CameraManager { + func changeCameraInput(_ position: CameraPosition) throws { + if let input = getCameraInput() { captureSession.remove(input: input) } + try captureSession.add(input: getCameraInput(position)) + } + func resetAttributesWhenChangingCamera(_ position: CameraPosition) { + resetAttributes(device: getCameraInput(position)?.device) + attributes.cameraPosition = position + } +} + +// MARK: Set Camera Zoom +extension CameraManager { + func setCameraZoomFactor(_ zoomFactor: CGFloat) throws { + guard let device = getCameraInput()?.device, zoomFactor != attributes.zoomFactor, !isChanging else { return } + + try setDeviceZoomFactor(zoomFactor, device) + attributes.zoomFactor = device.videoZoomFactor + } +} +private extension CameraManager { + func setDeviceZoomFactor(_ zoomFactor: CGFloat, _ device: any CaptureDevice) throws { + try device.lockForConfiguration() + device.setZoomFactor(zoomFactor) + device.unlockForConfiguration() + } +} + +// MARK: Set Camera Focus +extension CameraManager { + func setCameraFocus(at touchPoint: CGPoint) throws { + guard let device = getCameraInput()?.device, !isChanging else { return } + + let focusPoint = convertTouchPointToFocusPoint(touchPoint) + try setDeviceCameraFocus(focusPoint, device) + cameraMetalView.performCameraFocusAnimation(touchPoint: touchPoint) + } +} +private extension CameraManager { + func convertTouchPointToFocusPoint(_ touchPoint: CGPoint) -> CGPoint { .init( + x: touchPoint.y / cameraView.frame.height, + y: 1 - touchPoint.x / cameraView.frame.width + )} + func setDeviceCameraFocus(_ focusPoint: CGPoint, _ device: any CaptureDevice) throws { + try device.lockForConfiguration() + device.setFocusPointOfInterest(focusPoint) + device.setExposurePointOfInterest(focusPoint) + device.unlockForConfiguration() + } +} + +// MARK: Set Flash Mode +extension CameraManager { + func setFlashMode(_ flashMode: CameraFlashMode) { + guard let device = getCameraInput()?.device, device.hasFlash, flashMode != attributes.flashMode, !isChanging else { return } + attributes.flashMode = flashMode + } +} + +// MARK: Set Screen Flash Color +extension CameraManager { + func setScreenFlashColor(_ color: UIColor?) { + attributes.screenFlashColor = color + } +} + +// MARK: Set Light Mode +extension CameraManager { + func setLightMode(_ lightMode: CameraLightMode) throws { + guard let device = getCameraInput()?.device, device.hasTorch, lightMode != attributes.lightMode, !isChanging else { return } + + try setDeviceLightMode(lightMode, device) + attributes.lightMode = device.lightMode + } +} +private extension CameraManager { + func setDeviceLightMode(_ lightMode: CameraLightMode, _ device: any CaptureDevice) throws { + try device.lockForConfiguration() + device.setLightMode(lightMode) + device.unlockForConfiguration() + } +} + +// MARK: Set Mirror Output +extension CameraManager { + func setMirrorOutput(_ mirrorOutput: Bool) { + guard mirrorOutput != attributes.mirrorOutput, !isChanging else { return } + attributes.mirrorOutput = mirrorOutput + } +} + +// MARK: Set Grid Visibility +extension CameraManager { + func setGridVisibility(_ isGridVisible: Bool) { + guard isGridVisible != attributes.isGridVisible, !isChanging else { return } + cameraGridView.setVisibility(isGridVisible) + } +} + +// MARK: Set Camera Filters +extension CameraManager { + func setCameraFilters(_ cameraFilters: [CIFilter]) { + guard cameraFilters != attributes.cameraFilters, !isChanging else { return } + attributes.cameraFilters = cameraFilters + } +} + +// MARK: Set Exposure Mode +extension CameraManager { + func setExposureMode(_ exposureMode: AVCaptureDevice.ExposureMode) throws { + guard let device = getCameraInput()?.device, exposureMode != attributes.cameraExposure.mode, !isChanging else { return } + + try setDeviceExposureMode(exposureMode, device) + attributes.cameraExposure.mode = device.exposureMode + } +} +private extension CameraManager { + func setDeviceExposureMode(_ exposureMode: AVCaptureDevice.ExposureMode, _ device: any CaptureDevice) throws { + try device.lockForConfiguration() + device.setExposureMode(exposureMode, duration: attributes.cameraExposure.duration, iso: attributes.cameraExposure.iso) + device.unlockForConfiguration() + } +} + +// MARK: Set Exposure Duration +extension CameraManager { + func setExposureDuration(_ exposureDuration: CMTime) throws { + guard let device = getCameraInput()?.device, exposureDuration != attributes.cameraExposure.duration, !isChanging else { return } + + try setDeviceExposureDuration(exposureDuration, device) + attributes.cameraExposure.duration = device.exposureDuration + } +} +private extension CameraManager { + func setDeviceExposureDuration(_ exposureDuration: CMTime, _ device: any CaptureDevice) throws { + try device.lockForConfiguration() + device.setExposureMode(.custom, duration: exposureDuration, iso: attributes.cameraExposure.iso) + device.unlockForConfiguration() + } +} + +// MARK: Set ISO +extension CameraManager { + func setISO(_ iso: Float) throws { + guard let device = getCameraInput()?.device, iso != attributes.cameraExposure.iso, !isChanging else { return } + + try setDeviceISO(iso, device) + attributes.cameraExposure.iso = device.iso + } +} +private extension CameraManager { + func setDeviceISO(_ iso: Float, _ device: any CaptureDevice) throws { + try device.lockForConfiguration() + device.setExposureMode(.custom, duration: attributes.cameraExposure.duration, iso: iso) + device.unlockForConfiguration() + } +} + +// MARK: Set Exposure Target Bias +extension CameraManager { + func setExposureTargetBias(_ exposureTargetBias: Float) throws { + guard let device = getCameraInput()?.device, exposureTargetBias != attributes.cameraExposure.targetBias, !isChanging else { return } + + try setDeviceExposureTargetBias(exposureTargetBias, device) + attributes.cameraExposure.targetBias = device.exposureTargetBias + } +} +private extension CameraManager { + func setDeviceExposureTargetBias(_ exposureTargetBias: Float, _ device: any CaptureDevice) throws { + try device.lockForConfiguration() + device.setExposureTargetBias(exposureTargetBias) + device.unlockForConfiguration() + } +} + +// MARK: Set HDR Mode +extension CameraManager { + func setHDRMode(_ hdrMode: CameraHDRMode) throws { + guard let device = getCameraInput()?.device, hdrMode != attributes.hdrMode, !isChanging else { return } + + try setDeviceHDRMode(hdrMode, device) + attributes.hdrMode = hdrMode + } +} +private extension CameraManager { + func setDeviceHDRMode(_ hdrMode: CameraHDRMode, _ device: any CaptureDevice) throws { + try device.lockForConfiguration() + device.hdrMode = hdrMode + device.unlockForConfiguration() + } +} + +// MARK: Set Resolution +extension CameraManager { + func setResolution(_ resolution: AVCaptureSession.Preset) { + guard resolution != attributes.resolution, resolution != attributes.resolution, !isChanging else { return } + + captureSession.sessionPreset = resolution + attributes.resolution = resolution + } +} + +// MARK: Set Frame Rate +extension CameraManager { + func setFrameRate(_ frameRate: Int32) throws { + guard let device = getCameraInput()?.device, frameRate != attributes.frameRate, !isChanging else { return } + + try setDeviceFrameRate(frameRate, device) + attributes.frameRate = device.activeVideoMaxFrameDuration.timescale + } +} +private extension CameraManager { + func setDeviceFrameRate(_ frameRate: Int32, _ device: any CaptureDevice) throws { + try device.lockForConfiguration() + device.setFrameRate(frameRate) + device.unlockForConfiguration() + } +} + + +// MARK: - HELPERS + + + +// MARK: Attributes +extension CameraManager { + var hasFlash: Bool { getCameraInput()?.device.hasFlash ?? false } + var hasLight: Bool { getCameraInput()?.device.hasTorch ?? false } +} +private extension CameraManager { + var isChanging: Bool { cameraMetalView.isAnimating } +} + +// MARK: Methods +extension CameraManager { + func resetAttributes(device: (any CaptureDevice)?) { + guard let device else { return } + + var newAttributes = attributes + newAttributes.cameraExposure.mode = device.exposureMode + newAttributes.cameraExposure.duration = device.exposureDuration + newAttributes.cameraExposure.iso = device.iso + newAttributes.cameraExposure.targetBias = device.exposureTargetBias + newAttributes.frameRate = device.activeVideoMaxFrameDuration.timescale + newAttributes.zoomFactor = device.videoZoomFactor + newAttributes.lightMode = device.lightMode + newAttributes.hdrMode = device.hdrMode + + attributes = newAttributes + } + func getCameraInput(_ position: CameraPosition? = nil) -> (any CaptureDeviceInput)? { switch position ?? attributes.cameraPosition { + case .front: frontCameraInput + case .back: backCameraInput + }} +} diff --git a/Sources/Internal/Manager/Helpers/Capture Device Input/CaptureDeviceInput+AVCaptureDeviceInput.swift b/Sources/Internal/Manager/Helpers/Capture Device Input/CaptureDeviceInput+AVCaptureDeviceInput.swift new file mode 100644 index 0000000..b6ba338 --- /dev/null +++ b/Sources/Internal/Manager/Helpers/Capture Device Input/CaptureDeviceInput+AVCaptureDeviceInput.swift @@ -0,0 +1,26 @@ +// +// CaptureDeviceInput+AVCaptureDeviceInput.swift of MijickCamera +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import AVKit + +extension AVCaptureDeviceInput: CaptureDeviceInput { + static func get(mediaType: AVMediaType, position: AVCaptureDevice.Position?) -> Self? { + let device = { switch mediaType { + case .audio: AVCaptureDevice.default(for: .audio) + case .video where position == .front: AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front) + case .video where position == .back: AVCaptureDevice.default(for: .video) + default: fatalError() + }}() + + guard let device, let deviceInput = try? Self(device: device) else { return nil } + return deviceInput + } +} diff --git a/Sources/Internal/Manager/Helpers/Capture Device Input/CaptureDeviceInput+MockDeviceInput.swift b/Sources/Internal/Manager/Helpers/Capture Device Input/CaptureDeviceInput+MockDeviceInput.swift new file mode 100644 index 0000000..eaaf555 --- /dev/null +++ b/Sources/Internal/Manager/Helpers/Capture Device Input/CaptureDeviceInput+MockDeviceInput.swift @@ -0,0 +1,26 @@ +// +// CaptureDeviceInput+MockDeviceInput.swift of MijickCamera +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import AVKit + +class MockDeviceInput: NSObject, CaptureDeviceInput { required override init() {} + var device: MockCaptureDevice = .init() +} + +// MARK: Methods +extension MockDeviceInput { + static func get(mediaType: AVMediaType, position: AVCaptureDevice.Position?) -> Self? { .init() } +} + +// MARK: Equatable +extension MockDeviceInput { + static func == (lhs: MockDeviceInput, rhs: MockDeviceInput) -> Bool { lhs.device.uniqueID == rhs.device.uniqueID } +} diff --git a/Sources/Internal/Manager/Helpers/Capture Device Input/CaptureDeviceInput.swift b/Sources/Internal/Manager/Helpers/Capture Device Input/CaptureDeviceInput.swift new file mode 100644 index 0000000..d590156 --- /dev/null +++ b/Sources/Internal/Manager/Helpers/Capture Device Input/CaptureDeviceInput.swift @@ -0,0 +1,21 @@ +// +// CaptureDeviceInput.swift of MijickCamera +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import AVKit + +protocol CaptureDeviceInput: NSObject { + // MARK: Attributes + associatedtype CD: CaptureDevice + var device: CD { get } + + // MARK: Methods + static func get(mediaType: AVMediaType, position: AVCaptureDevice.Position?) -> Self? +} diff --git a/Sources/Internal/Manager/Helpers/Capture Device/CaptureDevice+AVCaptureDevice.swift b/Sources/Internal/Manager/Helpers/Capture Device/CaptureDevice+AVCaptureDevice.swift new file mode 100644 index 0000000..dacf5ca --- /dev/null +++ b/Sources/Internal/Manager/Helpers/Capture Device/CaptureDevice+AVCaptureDevice.swift @@ -0,0 +1,41 @@ +// +// CaptureDevice+AVCaptureDevice.swift of MijickCamera +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import AVKit + +// MARK: Getters +extension AVCaptureDevice: CaptureDevice { + var minExposureDuration: CMTime { activeFormat.minExposureDuration } + var maxExposureDuration: CMTime { activeFormat.maxExposureDuration } + var minISO: Float { activeFormat.minISO } + var maxISO: Float { activeFormat.maxISO } + var minFrameRate: Float64? { activeFormat.videoSupportedFrameRateRanges.first?.minFrameRate } + var maxFrameRate: Float64? { activeFormat.videoSupportedFrameRateRanges.first?.maxFrameRate } +} + +// MARK: Getters & Setters +extension AVCaptureDevice { + var lightMode: CameraLightMode { + get { torchMode == .off ? .off : .on } + set { torchMode = newValue == .off ? .off : .on } + } + var hdrMode: CameraHDRMode { + get { + if automaticallyAdjustsVideoHDREnabled { return .auto } + else if isVideoHDREnabled { return .on } + else { return .off } + } + set { + automaticallyAdjustsVideoHDREnabled = newValue == .auto + if newValue != .auto { isVideoHDREnabled = newValue == .on } + } + } +} diff --git a/Sources/Internal/Manager/Helpers/Capture Device/CaptureDevice+MockCaptureDevice.swift b/Sources/Internal/Manager/Helpers/Capture Device/CaptureDevice+MockCaptureDevice.swift new file mode 100644 index 0000000..ed56d15 --- /dev/null +++ b/Sources/Internal/Manager/Helpers/Capture Device/CaptureDevice+MockCaptureDevice.swift @@ -0,0 +1,62 @@ +// +// CaptureDevice+MockCaptureDevice.swift of MijickCamera +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import AVKit + +class MockCaptureDevice: NSObject, CaptureDevice { + // MARK: Getters + var uniqueID: String = UUID().uuidString + var exposureDuration: CMTime { _exposureDuration } + var exposureTargetBias: Float { _exposureTargetBias } + var iso: Float { _iso } + var minAvailableVideoZoomFactor: CGFloat { 1 } + var maxAvailableVideoZoomFactor: CGFloat { 3.876 } + var minExposureDuration: CMTime { .init(value: 1, timescale: 1000) } + var maxExposureDuration: CMTime { .init(value: 1, timescale: 5) } + var minISO: Float { 1 } + var maxISO: Float { 10 } + var minExposureTargetBias: Float { 0.1 } + var maxExposureTargetBias: Float { 199 } + var minFrameRate: Float64? { 15 } + var maxFrameRate: Float64? { 60 } + var hasFlash: Bool { true } + var hasTorch: Bool { true } + var isExposurePointOfInterestSupported: Bool { true } + var isFocusPointOfInterestSupported: Bool { true } + + // MARK: Setters + var videoZoomFactor: CGFloat = 1 + var focusMode: AVCaptureDevice.FocusMode = .autoFocus + var focusPointOfInterest: CGPoint = .zero + var exposurePointOfInterest: CGPoint = .zero + var lightMode: CameraLightMode = .off + var activeVideoMinFrameDuration: CMTime = .init() + var activeVideoMaxFrameDuration: CMTime = .init() + var exposureMode: AVCaptureDevice.ExposureMode = .continuousAutoExposure + var hdrMode: CameraHDRMode = .auto + + // MARK: Methods + func lockForConfiguration() throws { return } + func unlockForConfiguration() { return } + func isExposureModeSupported(_ exposureMode: AVCaptureDevice.ExposureMode) -> Bool { true } + func setExposureModeCustom(duration: CMTime, iso: Float, completionHandler: ((CMTime) -> Void)?) { + _exposureDuration = duration + _iso = iso + } + func setExposureTargetBias(_ bias: Float, completionHandler handler: ((CMTime) -> ())?) { + _exposureTargetBias = bias + } + + // MARK: Private Attributes + private var _exposureDuration: CMTime = .init() + private var _exposureTargetBias: Float = 0 + private var _iso: Float = 0 +} diff --git a/Sources/Internal/Manager/Helpers/Capture Device/CaptureDevice.swift b/Sources/Internal/Manager/Helpers/Capture Device/CaptureDevice.swift new file mode 100644 index 0000000..9ff7c49 --- /dev/null +++ b/Sources/Internal/Manager/Helpers/Capture Device/CaptureDevice.swift @@ -0,0 +1,131 @@ +// +// CaptureDevice.swift of MijickCamera +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import AVKit + +protocol CaptureDevice: NSObject { + // MARK: Getters + var uniqueID: String { get } + var exposureDuration: CMTime { get } + var exposureTargetBias: Float { get } + var iso: Float { get } + var minAvailableVideoZoomFactor: CGFloat { get } + var maxAvailableVideoZoomFactor: CGFloat { get } + var minExposureDuration: CMTime { get } + var maxExposureDuration: CMTime { get } + var minISO: Float { get } + var maxISO: Float { get } + var minExposureTargetBias: Float { get } + var maxExposureTargetBias: Float { get } + var minFrameRate: Float64? { get } + var maxFrameRate: Float64? { get } + var hasFlash: Bool { get } + var hasTorch: Bool { get } + var isExposurePointOfInterestSupported: Bool { get } + var isFocusPointOfInterestSupported: Bool { get } + + // MARK: Getters & Setters + var videoZoomFactor: CGFloat { get set } + var focusMode: AVCaptureDevice.FocusMode { get set } + var focusPointOfInterest: CGPoint { get set } + var exposurePointOfInterest: CGPoint { get set } + var lightMode: CameraLightMode { get set } + var activeVideoMinFrameDuration: CMTime { get set } + var activeVideoMaxFrameDuration: CMTime { get set } + var exposureMode: AVCaptureDevice.ExposureMode { get set } + var hdrMode: CameraHDRMode { get set } + + // MARK: Methods + func lockForConfiguration() throws + func unlockForConfiguration() + func isExposureModeSupported(_ exposureMode: AVCaptureDevice.ExposureMode) -> Bool + func setExposureModeCustom(duration: CMTime, iso: Float, completionHandler: (@Sendable (CMTime) -> Void)?) + func setExposureTargetBias(_ bias: Float, completionHandler handler: (@Sendable (CMTime) -> ())?) +} + + +// MARK: - METHODS + + + +// MARK: Set Zoom Factor +extension CaptureDevice { + func setZoomFactor(_ factor: CGFloat) { + let factor = max(min(factor, min(maxAvailableVideoZoomFactor, 5)), minAvailableVideoZoomFactor) + videoZoomFactor = factor + } +} + +// MARK: Set Focus Point Of Interest +extension CaptureDevice { + func setFocusPointOfInterest(_ point: CGPoint) { + guard isFocusPointOfInterestSupported else { return } + + focusPointOfInterest = point + focusMode = .autoFocus + } +} + +// MARK: Set Exposure Point Of Interest +extension CaptureDevice { + func setExposurePointOfInterest(_ point: CGPoint) { + guard isExposurePointOfInterestSupported else { return } + + exposurePointOfInterest = point + exposureMode = .autoExpose + } +} + +// MARK: Set Light Mode +extension CaptureDevice { + func setLightMode(_ mode: CameraLightMode) { + guard hasTorch else { return } + lightMode = mode + } +} + +// MARK: Set Frame Rate +extension CaptureDevice { + func setFrameRate(_ frameRate: Int32) { + guard let minFrameRate, let maxFrameRate else { return } + + let frameRate = max(min(frameRate, Int32(maxFrameRate)), Int32(minFrameRate)) + + activeVideoMinFrameDuration = CMTime(value: 1, timescale: frameRate) + activeVideoMaxFrameDuration = CMTime(value: 1, timescale: frameRate) + } +} + +// MARK: Set Exposure Mode +extension CaptureDevice { + func setExposureMode(_ mode: AVCaptureDevice.ExposureMode, duration: CMTime, iso: Float) { + guard isExposureModeSupported(mode) else { return } + + exposureMode = mode + + guard mode == .custom else { return } + + let duration = max(min(duration, maxExposureDuration), minExposureDuration) + let iso = max(min(iso, maxISO), minISO) + + setExposureModeCustom(duration: duration, iso: iso, completionHandler: nil) + } +} + +// MARK: Set Exposure Target Bias +extension CaptureDevice { + func setExposureTargetBias(_ bias: Float) { + guard isExposureModeSupported(.custom) else { return } + + let bias = max(min(bias, maxExposureTargetBias), minExposureTargetBias) + setExposureTargetBias(bias, completionHandler: nil) + } +} diff --git a/Sources/Internal/Manager/Helpers/Capture Session/CaptureSession+AVCaptureSession.swift b/Sources/Internal/Manager/Helpers/Capture Session/CaptureSession+AVCaptureSession.swift new file mode 100644 index 0000000..e9c14b6 --- /dev/null +++ b/Sources/Internal/Manager/Helpers/Capture Session/CaptureSession+AVCaptureSession.swift @@ -0,0 +1,45 @@ +// +// CaptureSession+AVCaptureSession.swift of MijickCamera +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import AVKit + +extension AVCaptureSession: @unchecked @retroactive Sendable {} +extension AVCaptureSession: CaptureSession { + var deviceInputs: [any CaptureDeviceInput] { inputs as? [any CaptureDeviceInput] ?? [] } +} + + +// MARK: - METHODS + + + +extension AVCaptureSession { + func stopRunningAndReturnNewInstance() -> any CaptureSession { + self.stopRunning() + return AVCaptureSession() + } +} +extension AVCaptureSession { + func add(input: (any CaptureDeviceInput)?) throws(MCameraError) { + guard let input = input as? AVCaptureDeviceInput else { throw .cannotSetupInput } + if canAddInput(input) { addInput(input) } + } + func remove(input: (any CaptureDeviceInput)?) { + guard let input = input as? AVCaptureDeviceInput else { return } + removeInput(input) + } +} +extension AVCaptureSession { + func add(output: AVCaptureOutput?) throws(MCameraError) { + guard let output else { throw .cannotSetupOutput } + if canAddOutput(output) { addOutput(output) } + } +} diff --git a/Sources/Internal/Manager/Helpers/Capture Session/CaptureSession+MockCaptureSession.swift b/Sources/Internal/Manager/Helpers/Capture Session/CaptureSession+MockCaptureSession.swift new file mode 100644 index 0000000..7ce47e5 --- /dev/null +++ b/Sources/Internal/Manager/Helpers/Capture Session/CaptureSession+MockCaptureSession.swift @@ -0,0 +1,57 @@ +// +// CaptureSession+MockCaptureSession.swift of MijickCamera +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import AVKit + +extension MockCaptureSession: @unchecked Sendable {} +class MockCaptureSession: NSObject, CaptureSession { required override init() {} + // MARK: Attributes + var isRunning: Bool { _isRunning } + var deviceInputs: [any CaptureDeviceInput] { _deviceInputs } + var outputs: [AVCaptureOutput] { _outputs } + var sessionPreset: AVCaptureSession.Preset = .cif352x288 + + // MARK: Private Attributes + private var _isRunning: Bool = false + private var _deviceInputs: [any CaptureDeviceInput] = [] + private var _outputs: [AVCaptureOutput] = [] +} + + +// MARK: - METHODS + + + +extension MockCaptureSession { + func startRunning() { Task { @MainActor in + _isRunning = true + }} + func stopRunningAndReturnNewInstance() -> any CaptureSession { + _isRunning = false + return MockCaptureSession() + } +} +extension MockCaptureSession { + func add(input: (any CaptureDeviceInput)?) throws(MCameraError) { + guard let input = input as? MockDeviceInput, !_deviceInputs.contains(where: { input == $0 }) else { throw .cannotSetupInput } + _deviceInputs.append(input) + } + func remove(input: (any CaptureDeviceInput)?) { + guard let input = input as? MockDeviceInput, let index = _deviceInputs.firstIndex(where: { $0.device.uniqueID == input.device.uniqueID }) else { return } + _deviceInputs.remove(at: index) + } +} +extension MockCaptureSession { + func add(output: AVCaptureOutput?) throws(MCameraError) { + guard let output, !outputs.contains(output) else { throw .cannotSetupOutput } + _outputs.append(output) + } +} diff --git a/Sources/Internal/Manager/Helpers/Capture Session/CaptureSession.swift b/Sources/Internal/Manager/Helpers/Capture Session/CaptureSession.swift new file mode 100644 index 0000000..4bf2587 --- /dev/null +++ b/Sources/Internal/Manager/Helpers/Capture Session/CaptureSession.swift @@ -0,0 +1,27 @@ +// +// CaptureSession.swift of MijickCamera +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import AVKit + +protocol CaptureSession: Sendable { + // MARK: Attributes + var isRunning: Bool { get } + var deviceInputs: [any CaptureDeviceInput] { get } + var outputs: [AVCaptureOutput] { get } + var sessionPreset: AVCaptureSession.Preset { get set } + + // MARK: Methods + func startRunning() + func stopRunningAndReturnNewInstance() -> CaptureSession + func add(input: (any CaptureDeviceInput)?) throws(MCameraError) + func remove(input: (any CaptureDeviceInput)?) + func add(output: AVCaptureOutput?) throws(MCameraError) +} diff --git a/Sources/Internal/Miscellaneous/Typealiases.swift b/Sources/Internal/Miscellaneous/Typealiases.swift new file mode 100644 index 0000000..5675600 --- /dev/null +++ b/Sources/Internal/Miscellaneous/Typealiases.swift @@ -0,0 +1,16 @@ +// +// Typealiases.swift of MijickCamera +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import SwiftUI + +public typealias CameraScreenBuilder = @MainActor (CameraManager, Namespace.ID, _ closeMCameraAction: @escaping () -> ()) -> any MCameraScreen +public typealias CapturedMediaScreenBuilder = @MainActor (MCameraMedia, Namespace.ID, _ retakeAction: @escaping () -> (), _ acceptMediaAction: @escaping () -> ()) -> any MCapturedMediaScreen +public typealias ErrorScreenBuilder = @MainActor (MCameraError, _ closeMCameraAction: @escaping () -> ()) -> any MCameraErrorScreen diff --git a/Sources/Internal/Models/CameraExposure.swift b/Sources/Internal/Models/CameraExposure.swift new file mode 100644 index 0000000..55fb42a --- /dev/null +++ b/Sources/Internal/Models/CameraExposure.swift @@ -0,0 +1,19 @@ +// +// CameraExposure.swift of MijickCamera +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import AVKit + +struct CameraExposure { + var duration: CMTime = .zero + var targetBias: Float = 0 + var iso: Float = 0 + var mode: AVCaptureDevice.ExposureMode = .autoExpose +} diff --git a/Sources/Internal/Models/MCameraMedia.swift b/Sources/Internal/Models/MCameraMedia.swift new file mode 100644 index 0000000..bd055ff --- /dev/null +++ b/Sources/Internal/Models/MCameraMedia.swift @@ -0,0 +1,28 @@ +// +// MCameraMedia.swift of MijickCamera +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import SwiftUI + +public struct MCameraMedia: Sendable { + let image: UIImage? + let video: URL? + + init?(data: Any?) { + if let image = data as? UIImage { self.image = image; self.video = nil } + else if let video = data as? URL { self.video = video; self.image = nil } + else { return nil } + } +} + +// MARK: Equatable +extension MCameraMedia: Equatable { + public static func == (lhs: MCameraMedia, rhs: MCameraMedia) -> Bool { lhs.image == rhs.image && lhs.video == rhs.video } +} diff --git a/Sources/Internal/UI/Camera View/CameraView+Bridge.swift b/Sources/Internal/UI/Camera View/CameraView+Bridge.swift new file mode 100644 index 0000000..e3b2f6a --- /dev/null +++ b/Sources/Internal/UI/Camera View/CameraView+Bridge.swift @@ -0,0 +1,68 @@ +// +// CameraView+Bridge.swift of MijickCamera +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import SwiftUI + +struct CameraBridgeView: UIViewRepresentable { + let cameraManager: CameraManager + let inputView: UIView = .init() +} +extension CameraBridgeView { + func makeUIView(context: Context) -> some UIView { + cameraManager.initialize(in: inputView) + setupTapGesture(context) + setupPinchGesture(context) + return inputView + } + func updateUIView(_ uiView: UIViewType, context: Context) {} + func makeCoordinator() -> Coordinator { .init(self) } +} +private extension CameraBridgeView { + func setupTapGesture(_ context: Context) { + let tapRecognizer = UITapGestureRecognizer(target: context.coordinator, action: #selector(context.coordinator.onTapGesture)) + inputView.addGestureRecognizer(tapRecognizer) + } + func setupPinchGesture(_ context: Context) { + let pinchRecognizer = UIPinchGestureRecognizer(target: context.coordinator, action: #selector(context.coordinator.onPinchGesture)) + inputView.addGestureRecognizer(pinchRecognizer) + } +} + +// MARK: Equatable +extension CameraBridgeView: Equatable { + nonisolated static func ==(lhs: Self, rhs: Self) -> Bool { true } +} + + +// MARK: - GESTURES +extension CameraBridgeView { class Coordinator: NSObject { init(_ parent: CameraBridgeView) { self.parent = parent } + let parent: CameraBridgeView +}} + +// MARK: On Tap +extension CameraBridgeView.Coordinator { + @MainActor @objc func onTapGesture(_ tap: UITapGestureRecognizer) { + do { + let touchPoint = tap.location(in: parent.inputView) + try parent.cameraManager.setCameraFocus(at: touchPoint) + } catch {} + } +} + +// MARK: On Pinch +extension CameraBridgeView.Coordinator { + @MainActor @objc func onPinchGesture(_ pinch: UIPinchGestureRecognizer) { if pinch.state == .changed { + do { + let desiredZoomFactor = parent.cameraManager.attributes.zoomFactor + atan2(pinch.velocity, 33) + try parent.cameraManager.setCameraZoomFactor(desiredZoomFactor) + } catch {} + }} +} diff --git a/Sources/Internal/UI/Camera View/CameraView+FocusIndicator.swift b/Sources/Internal/UI/Camera View/CameraView+FocusIndicator.swift new file mode 100644 index 0000000..5c68200 --- /dev/null +++ b/Sources/Internal/UI/Camera View/CameraView+FocusIndicator.swift @@ -0,0 +1,33 @@ +// +// CameraView+FocusIndicator.swift of MijickCamera +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import SwiftUI + +@MainActor class CameraFocusIndicatorView { + var image: UIImage = .init(resource: .mijickIconCrosshair) + var tintColor: UIColor = .init(resource: .mijickBackgroundYellow) + var size: CGFloat = 96 +} + +// MARK: Create +extension CameraFocusIndicatorView { + func create(at touchPoint: CGPoint) -> UIImageView { + let focusIndicator = UIImageView(image: image) + focusIndicator.contentMode = .scaleAspectFit + focusIndicator.tintColor = tintColor + focusIndicator.frame.size = .init(width: size, height: size) + focusIndicator.frame.origin.x = touchPoint.x - size / 2 + focusIndicator.frame.origin.y = touchPoint.y - size / 2 + focusIndicator.transform = .init(scaleX: 0, y: 0) + focusIndicator.tag = .focusIndicatorTag + return focusIndicator + } +} diff --git a/Sources/Internal/UI/Camera View/CameraView+Grid.swift b/Sources/Internal/UI/Camera View/CameraView+Grid.swift new file mode 100644 index 0000000..2648d79 --- /dev/null +++ b/Sources/Internal/UI/Camera View/CameraView+Grid.swift @@ -0,0 +1,81 @@ +// +// CameraView+Grid.swift of MijickCamera +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import SwiftUI + +class CameraGridView: UIView { + var parent: CameraManager! +} + +// MARK: Setup +extension CameraGridView { + func setup(parent: CameraManager) { + self.parent = parent + self.alpha = parent.attributes.isGridVisible ? 1 : 0 + self.addToParent(parent.cameraView) + } +} + +// MARK: Set Visibility +extension CameraGridView { + func setVisibility(_ isVisible: Bool) { + UIView.animate(withDuration: 0.2) { self.alpha = isVisible ? 1 : 0 } + parent.attributes.isGridVisible = isVisible + } +} + +// MARK: Draw +extension CameraGridView { + override func draw(_ rect: CGRect) { + clearOldLayersBeforeDraw() + + let firstColumnPath = UIBezierPath() + firstColumnPath.move(to: CGPoint(x: bounds.width / 3, y: 0)) + firstColumnPath.addLine(to: CGPoint(x: bounds.width / 3, y: bounds.height)) + let firstColumnLayer = createGridLayer() + firstColumnLayer.path = firstColumnPath.cgPath + layer.addSublayer(firstColumnLayer) + + let secondColumnPath = UIBezierPath() + secondColumnPath.move(to: CGPoint(x: (2 * bounds.width) / 3, y: 0)) + secondColumnPath.addLine(to: CGPoint(x: (2 * bounds.width) / 3, y: bounds.height)) + let secondColumnLayer = createGridLayer() + secondColumnLayer.path = secondColumnPath.cgPath + layer.addSublayer(secondColumnLayer) + + let firstRowPath = UIBezierPath() + firstRowPath.move(to: CGPoint(x: 0, y: bounds.height / 3)) + firstRowPath.addLine(to: CGPoint(x: bounds.width, y: bounds.height / 3)) + let firstRowLayer = createGridLayer() + firstRowLayer.path = firstRowPath.cgPath + layer.addSublayer(firstRowLayer) + + let secondRowPath = UIBezierPath() + secondRowPath.move(to: CGPoint(x: 0, y: ( 2 * bounds.height) / 3)) + secondRowPath.addLine(to: CGPoint(x: bounds.width, y: ( 2 * bounds.height) / 3)) + let secondRowLayer = createGridLayer() + secondRowLayer.path = secondRowPath.cgPath + layer.addSublayer(secondRowLayer) + } +} +private extension CameraGridView { + func clearOldLayersBeforeDraw() { + layer.sublayers?.removeAll() + layer.backgroundColor = .none + } + func createGridLayer() -> CAShapeLayer { + let shapeLayer = CAShapeLayer() + shapeLayer.strokeColor = UIColor(white: 1.0, alpha: 0.2).cgColor + shapeLayer.frame = bounds + shapeLayer.fillColor = nil + return shapeLayer + } +} diff --git a/Sources/Internal/UI/Camera View/CameraView+Metal.swift b/Sources/Internal/UI/Camera View/CameraView+Metal.swift new file mode 100644 index 0000000..ed65653 --- /dev/null +++ b/Sources/Internal/UI/Camera View/CameraView+Metal.swift @@ -0,0 +1,279 @@ +// +// CameraView+Metal.swift of MijickCamera +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import SwiftUI +import MetalKit +import AVKit + +@MainActor class CameraMetalView: MTKView { + private(set) var parent: CameraManager! + private(set) var ciContext: CIContext! + private(set) var commandQueue: MTLCommandQueue! + private(set) var currentFrame: CIImage? + private(set) var focusIndicator: CameraFocusIndicatorView = .init() + private(set) var isAnimating: Bool = false +} + +// MARK: Setup +extension CameraMetalView { + func setup(parent: CameraManager) throws(MCameraError) { + guard let metalDevice = MTLCreateSystemDefaultDevice() else { throw .cannotSetupMetalDevice } + + self.assignInitialValues(parent: parent, metalDevice: metalDevice) + self.configureMetalView(metalDevice: metalDevice) + self.addToParent(parent.cameraView) + } +} +private extension CameraMetalView { + func assignInitialValues(parent: CameraManager, metalDevice: MTLDevice) { + self.parent = parent + self.ciContext = CIContext(mtlDevice: metalDevice) + self.commandQueue = metalDevice.makeCommandQueue() + } + func configureMetalView(metalDevice: MTLDevice) { + self.parent.cameraView.alpha = 0 + + self.delegate = self + self.device = metalDevice + self.isPaused = true + self.enableSetNeedsDisplay = false + self.framebufferOnly = false + self.autoResizeDrawable = false + self.contentMode = .scaleAspectFill + self.clipsToBounds = true + } +} + + +// MARK: - ANIMATIONS + + + +// MARK: Camera Entrance +extension CameraMetalView { + func performCameraEntranceAnimation() { UIView.animate(withDuration: 0.33) { [self] in + parent.cameraView.alpha = 1 + }} +} + +// MARK: Image Capture +extension CameraMetalView { + func performImageCaptureAnimation() { + let blackMatte = createBlackMatte() + + parent.cameraView.addSubview(blackMatte) + animateBlackMatte(blackMatte) + } + + /// Shows screen flash for front camera, calls completion when ready to capture + func performScreenFlash(completion: @escaping @MainActor () -> Void) { + guard let cameraView = parent?.cameraView else { + completion() + return + } + + let flashColor = parent.attributes.screenFlashColor ?? .white + let flashView = createScreenFlashView(color: flashColor) + let originalBrightness = UIScreen.main.brightness + + // Add flash overlay to the window for full screen coverage + if let window = cameraView.window { + flashView.frame = window.bounds + window.addSubview(flashView) + } else { + flashView.frame = cameraView.bounds + cameraView.addSubview(flashView) + } + + // Boost brightness and show flash + UIScreen.main.brightness = 1.0 + UIView.animate(withDuration: 0.05, animations: { + flashView.alpha = 1.0 + }) { _ in + // Small delay for camera to adjust to new lighting, then capture + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(100)) + completion() + + // Fade out flash after capture is triggered + try? await Task.sleep(for: .milliseconds(150)) + UIView.animate(withDuration: 0.2, animations: { + flashView.alpha = 0 + }) { _ in + flashView.removeFromSuperview() + UIScreen.main.brightness = originalBrightness + } + } + } + } +} +private extension CameraMetalView { + func createBlackMatte() -> UIView { + let view = UIView() + view.frame = parent.cameraView.frame + view.backgroundColor = .init(resource: .mijickBackgroundPrimary) + view.alpha = 0 + return view + } + func animateBlackMatte(_ view: UIView) { + UIView.animate(withDuration: 0.16, animations: { view.alpha = 1 }) { _ in + UIView.animate(withDuration: 0.16, animations: { view.alpha = 0 }) { _ in + view.removeFromSuperview() + } + } + } + func createScreenFlashView(color: UIColor) -> UIView { + let view = UIView() + view.frame = UIScreen.main.bounds + view.backgroundColor = color + view.alpha = 0 + return view + } +} + +// MARK: Camera Flip +extension CameraMetalView { + func beginCameraFlipAnimation() async { + let snapshot = createSnapshot() + isAnimating = true + insertBlurView(snapshot) + animateBlurFlip() + + await Task.sleep(seconds: 0.01) + } + func finishCameraFlipAnimation() async { + guard let blurView = parent.cameraView.viewWithTag(.blurViewTag) else { return } + + await Task.sleep(seconds: 0.44) + UIView.animate(withDuration: 0.3, animations: { blurView.alpha = 0 }) { [self] _ in + blurView.removeFromSuperview() + isAnimating = false + } + } +} +private extension CameraMetalView { + func createSnapshot() -> UIImage? { + guard let currentFrame else { return nil } + + let image = UIImage(ciImage: currentFrame) + return image + } + func insertBlurView(_ snapshot: UIImage?) { + let blurView = UIImageView(frame: parent.cameraView.frame) + blurView.image = snapshot + blurView.contentMode = .scaleAspectFill + blurView.clipsToBounds = true + blurView.tag = .blurViewTag + blurView.applyBlurEffect(style: .regular) + + parent.cameraView.addSubview(blurView) + } + func animateBlurFlip() { + UIView.transition(with: parent.cameraView, duration: 0.44, options: cameraFlipAnimationTransition) {} + } +} +private extension CameraMetalView { + var cameraFlipAnimationTransition: UIView.AnimationOptions { parent.attributes.cameraPosition == .back ? .transitionFlipFromLeft : .transitionFlipFromRight } +} + +// MARK: Camera Focus +extension CameraMetalView { + func performCameraFocusAnimation(touchPoint: CGPoint) { + removeExistingFocusIndicatorAnimations() + + let focusIndicator = focusIndicator.create(at: touchPoint) + parent.cameraView.addSubview(focusIndicator) + animateFocusIndicator(focusIndicator) + } +} +private extension CameraMetalView { + func removeExistingFocusIndicatorAnimations() { if let view = parent.cameraView.viewWithTag(.focusIndicatorTag) { + view.removeFromSuperview() + }} + func animateFocusIndicator(_ focusIndicator: UIImageView) { + UIView.animate(withDuration: 0.44, delay: 0, usingSpringWithDamping: 0.6, initialSpringVelocity: 0, animations: { focusIndicator.transform = .init(scaleX: 1, y: 1) }) { _ in + UIView.animate(withDuration: 0.44, delay: 1.44, animations: { focusIndicator.alpha = 0.2 }) { _ in + UIView.animate(withDuration: 0.44, delay: 1.44, animations: { focusIndicator.alpha = 0 }) + } + } + } +} + +// MARK: Camera Orientation +extension CameraMetalView { + func beginCameraOrientationAnimation(if shouldAnimate: Bool) async { if shouldAnimate { + parent.cameraView.alpha = 0 + await Task.sleep(seconds: 0.1) + }} + func finishCameraOrientationAnimation(if shouldAnimate: Bool) { if shouldAnimate { + UIView.animate(withDuration: 0.2, delay: 0.1) { self.parent.cameraView.alpha = 1 } + }} +} + + +// MARK: - CAPTURING FRAMES + + + +// MARK: Capture +extension CameraMetalView: @preconcurrency AVCaptureVideoDataOutputSampleBufferDelegate { + func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) { + guard let cvImageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return } + + let currentFrame = captureCurrentFrame(cvImageBuffer) + let currentFrameWithFiltersApplied = applyingFiltersToCurrentFrame(currentFrame) + redrawCameraView(currentFrameWithFiltersApplied) + } +} +private extension CameraMetalView { + func captureCurrentFrame(_ cvImageBuffer: CVImageBuffer) -> CIImage { + let currentFrame = CIImage(cvImageBuffer: cvImageBuffer) + return currentFrame.oriented(parent.attributes.frameOrientation) + } + func applyingFiltersToCurrentFrame(_ currentFrame: CIImage) -> CIImage { + currentFrame.applyingFilters(parent.attributes.cameraFilters) + } + func redrawCameraView(_ frame: CIImage) { + currentFrame = frame + draw() + } +} + +// MARK: Draw +extension CameraMetalView: MTKViewDelegate { + func draw(in view: MTKView) { + guard let commandBuffer = commandQueue.makeCommandBuffer(), + let ciImage = currentFrame, + let currentDrawable = view.currentDrawable + else { return } + + changeDrawableSize(view, ciImage) + renderView(view, currentDrawable, commandBuffer, ciImage) + commitBuffer(currentDrawable, commandBuffer) + } + func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {} +} +private extension CameraMetalView { + func changeDrawableSize(_ view: MTKView, _ ciImage: CIImage) { + view.drawableSize = ciImage.extent.size + } + func renderView(_ view: MTKView, _ currentDrawable: any CAMetalDrawable, _ commandBuffer: any MTLCommandBuffer, _ ciImage: CIImage) { ciContext.render( + ciImage, + to: currentDrawable.texture, + commandBuffer: commandBuffer, + bounds: .init(origin: .zero, size: view.drawableSize), + colorSpace: CGColorSpaceCreateDeviceRGB() + )} + func commitBuffer(_ currentDrawable: any CAMetalDrawable, _ commandBuffer: any MTLCommandBuffer) { + commandBuffer.present(currentDrawable) + commandBuffer.commit() + } +} diff --git a/Sources/Internal/UI/Default Screens/Camera/DefaultCameraScreen+BottomBar.swift b/Sources/Internal/UI/Default Screens/Camera/DefaultCameraScreen+BottomBar.swift new file mode 100644 index 0000000..089c7e9 --- /dev/null +++ b/Sources/Internal/UI/Default Screens/Camera/DefaultCameraScreen+BottomBar.swift @@ -0,0 +1,97 @@ +// +// DefaultCameraScreen+BottomBar.swift of MijickCamera +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import SwiftUI + +extension DefaultCameraScreen { struct BottomBar: View { + let parent: DefaultCameraScreen + + + var body: some View { + ZStack(alignment: .top) { + createOutputTypeSwitch() + createButtons() + } + .frame(maxWidth: .infinity) + .padding(.bottom, 44) + .padding(.horizontal, 32) + } +}} +private extension DefaultCameraScreen.BottomBar { + @ViewBuilder func createOutputTypeSwitch() -> some View { if isOutputTypeSwitchActive { + DefaultCameraScreen.CameraOutputSwitch(parent: parent) + .offset(y: -80) + }} + func createButtons() -> some View { + ZStack { + createLightButton() + createCaptureButton() + createChangeCameraPositionButton() + }.frame(height: 72) + } +} +private extension DefaultCameraScreen.BottomBar { + @ViewBuilder func createLightButton() -> some View { if isLightButtonActive { + BottomButton( + icon: .mijickIconLight, + iconColor: lightButtonIconColor, + backgroundColor: .init(.mijickBackgroundSecondary), + rotationAngle: parent.iconAngle, + action: changeLightMode + ) + .frame(maxWidth: .infinity, alignment: .leading) + .transition(.scale) + }} + @ViewBuilder func createCaptureButton() -> some View { if isCaptureButtonActive { + DefaultCameraScreen.CaptureButton( + outputType: parent.cameraOutputType, + isRecording: parent.isRecording, + action: parent.captureOutput + ) + .transition(.scale) + }} + @ViewBuilder func createChangeCameraPositionButton() -> some View { if isChangeCameraPositionButtonActive { + BottomButton( + icon: .mijickIconChangeCamera, + iconColor: changeCameraPositionButtonIconColor, + backgroundColor: .init(.mijickBackgroundSecondary), + rotationAngle: parent.iconAngle, + action: changeCameraPosition + ) + .frame(maxWidth: .infinity, alignment: .trailing) + .transition(.scale) + }} +} + +private extension DefaultCameraScreen.BottomBar { + func changeLightMode() { + do { try parent.setLightMode(parent.lightMode.next()) } + catch {} + } + func changeCameraPosition() { Task { + do { try await parent.setCameraPosition(parent.cameraPosition.next()) } + catch {} + }} +} + +private extension DefaultCameraScreen.BottomBar { + var lightButtonIconColor: Color { switch parent.lightMode { + case .on: .init(.mijickBackgroundYellow) + case .off: .init(.mijickBackgroundInverted) + }} + var changeCameraPositionButtonIconColor: Color { .init(.mijickBackgroundInverted) } +} +private extension DefaultCameraScreen.BottomBar { + var isOutputTypeSwitchActive: Bool { parent.config.cameraOutputSwitchAllowed && parent.cameraManager.captureSession.isRunning && !parent.isRecording } + var isLightButtonActive: Bool { parent.config.lightButtonAllowed && parent.hasLight && parent.cameraManager.captureSession.isRunning && !parent.isRecording } + var isCaptureButtonActive: Bool { parent.config.captureButtonAllowed && parent.cameraManager.captureSession.isRunning } + var isChangeCameraPositionButtonActive: Bool { parent.config.cameraPositionButtonAllowed && parent.cameraManager.captureSession.isRunning && !parent.isRecording } +} diff --git a/Sources/Internal/UI/Default Screens/Camera/DefaultCameraScreen+ButtonScaleStyle.swift b/Sources/Internal/UI/Default Screens/Camera/DefaultCameraScreen+ButtonScaleStyle.swift new file mode 100644 index 0000000..be11197 --- /dev/null +++ b/Sources/Internal/UI/Default Screens/Camera/DefaultCameraScreen+ButtonScaleStyle.swift @@ -0,0 +1,19 @@ +// +// DefaultCameraScreen+ButtonScaleStyle.swift of MijickCamera +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import SwiftUI + +struct ButtonScaleStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { configuration + .label + .scaleEffect(configuration.isPressed ? 0.96 : 1) + } +} diff --git a/Sources/Internal/UI/Default Screens/Camera/DefaultCameraScreen+CameraOutputSwitch.swift b/Sources/Internal/UI/Default Screens/Camera/DefaultCameraScreen+CameraOutputSwitch.swift new file mode 100644 index 0000000..1ff1822 --- /dev/null +++ b/Sources/Internal/UI/Default Screens/Camera/DefaultCameraScreen+CameraOutputSwitch.swift @@ -0,0 +1,79 @@ +// +// DefaultCameraScreen+CameraOutputSwitch.swift of MijickCamera +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import SwiftUI + +extension DefaultCameraScreen { struct CameraOutputSwitch: View { + let parent: DefaultCameraScreen + + + var body: some View { + HStack(spacing: 4) { + createOutputTypeButton(.video) + createOutputTypeButton(.photo) + } + .padding(8) + .background(Color(.mijickBackgroundPrimary50)) + .mask(Capsule()) + } +}} +private extension DefaultCameraScreen.CameraOutputSwitch { + func createOutputTypeButton(_ outputType: CameraOutputType) -> some View { + Button(icon: getOutputTypeButtonIcon(outputType), active: isOutputTypeButtonActive(outputType)) { + parent.setOutputType(outputType) + } + .rotationEffect(parent.iconAngle) + } +} + +private extension DefaultCameraScreen.CameraOutputSwitch { + func getOutputTypeButtonIcon(_ outputType: CameraOutputType) -> ImageResource { switch outputType { + case .photo: return .mijickIconPhoto + case .video: return .mijickIconVideo + }} + func isOutputTypeButtonActive(_ outputType: CameraOutputType) -> Bool { + outputType == parent.cameraOutputType + } +} + + +// MARK: Button +fileprivate struct Button: View { + let icon: ImageResource + let active: Bool + let action: () -> () + + + var body: some View { + SwiftUI.Button(action: action, label: createButtonLabel).buttonStyle(ButtonScaleStyle()) + } +} +private extension Button { + func createButtonLabel() -> some View { + Image(icon) + .resizable() + .frame(width: iconSize, height: iconSize) + .foregroundColor(iconColor) + .padding(8) + .background(Color(.mijickBackgroundSecondary)) + .mask(Circle()) + } +} +private extension Button { + var iconSize: CGFloat { switch active { + case true: 28 + case false: 20 + }} + var iconColor: Color { switch active { + case true: .init(.mijickBackgroundYellow) + case false: .init(.mijickTextTertiary) + }} +} diff --git a/Sources/Internal/UI/Default Screens/Camera/DefaultCameraScreen+CaptureButton.swift b/Sources/Internal/UI/Default Screens/Camera/DefaultCameraScreen+CaptureButton.swift new file mode 100644 index 0000000..9ced158 --- /dev/null +++ b/Sources/Internal/UI/Default Screens/Camera/DefaultCameraScreen+CaptureButton.swift @@ -0,0 +1,55 @@ +// +// DefaultCameraScreen+CaptureButton.swift of MijickCamera +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import SwiftUI + +extension DefaultCameraScreen { struct CaptureButton: View { + let outputType: CameraOutputType + let isRecording: Bool + let action: () -> () + + + var body: some View { + Button(action: action, label: createButtonLabel).buttonStyle(ButtonScaleStyle()) + } +}} +private extension DefaultCameraScreen.CaptureButton { + func createButtonLabel() -> some View { + ZStack { + createBackground() + createBorders() + }.frame(width: 72, height: 72) + } +} +private extension DefaultCameraScreen.CaptureButton { + func createBackground() -> some View { + RoundedRectangle(cornerRadius: backgroundCornerRadius, style: .continuous) + .fill(backgroundColor) + .padding(backgroundPadding) + } + func createBorders() -> some View { + Circle().stroke(Color(.mijickBackgroundInverted), lineWidth: 2.5) + } +} +private extension DefaultCameraScreen.CaptureButton { + var backgroundColor: Color { switch outputType { + case .photo: .init(.mijickBackgroundInverted) + case .video: .init(.mijickBackgroundRed) + }} + var backgroundCornerRadius: CGFloat { switch isRecording { + case true: 6 + case false: 36 + }} + var backgroundPadding: CGFloat { switch isRecording { + case true: 20 + case false: 4 + }} +} diff --git a/Sources/Internal/UI/Default Screens/Camera/DefaultCameraScreen+Config.swift b/Sources/Internal/UI/Default Screens/Camera/DefaultCameraScreen+Config.swift new file mode 100644 index 0000000..17c19f1 --- /dev/null +++ b/Sources/Internal/UI/Default Screens/Camera/DefaultCameraScreen+Config.swift @@ -0,0 +1,23 @@ +// +// DefaultCameraScreen+Config.swift of MijickCamera +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import Foundation + +extension DefaultCameraScreen { class Config { + var captureButtonAllowed: Bool = true + var cameraOutputSwitchAllowed: Bool = true + var cameraPositionButtonAllowed: Bool = true + var flashButtonAllowed: Bool = true + var lightButtonAllowed: Bool = true + var flipButtonAllowed: Bool = true + var gridButtonAllowed: Bool = true + var closeButtonAllowed: Bool = true +}} diff --git a/Sources/Internal/UI/Default Screens/Camera/DefaultCameraScreen+TopBar.swift b/Sources/Internal/UI/Default Screens/Camera/DefaultCameraScreen+TopBar.swift new file mode 100644 index 0000000..f4d1940 --- /dev/null +++ b/Sources/Internal/UI/Default Screens/Camera/DefaultCameraScreen+TopBar.swift @@ -0,0 +1,116 @@ +// +// DefaultCameraScreen+TopBar.swift of MijickCamera +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import SwiftUI + +extension DefaultCameraScreen { struct TopBar: View { + let parent: DefaultCameraScreen + + + var body: some View { if isTopBarActive { + ZStack { + createCloseButton() + createCentralView() + createRightSideView() + } + .frame(maxWidth: .infinity) + .padding(.top, topPadding) + .padding(.bottom, 8) + .padding(.horizontal, 20) + .background(Color(.mijickBackgroundPrimary80)) + .transition(.move(edge: .top)) + }} +}} +private extension DefaultCameraScreen.TopBar { + @ViewBuilder func createCloseButton() -> some View { if isCloseButtonActive { + CloseButton(action: parent.closeMCameraAction) + .frame(maxWidth: .infinity, alignment: .leading) + }} + @ViewBuilder func createCentralView() -> some View { if isCentralViewActive { + Text(parent.recordingTime.toString()) + .font(.system(size: 20, weight: .medium)) + .foregroundColor(.init(.mijickTextPrimary)) + }} + @ViewBuilder func createRightSideView() -> some View { if isRightSideViewActive { + HStack(spacing: 12) { + createGridButton() + createFlipOutputButton() + createFlashButton() + } + .frame(maxWidth: .infinity, alignment: .trailing) + }} +} +private extension DefaultCameraScreen.TopBar { + @ViewBuilder func createGridButton() -> some View { if isGridButtonActive { + DefaultCameraScreen.TopButton( + icon: gridButtonIcon, + iconRotationAngle: parent.iconAngle, + action: changeGridVisibility + ) + }} + @ViewBuilder func createFlipOutputButton() -> some View { if isFlipOutputButtonActive { + DefaultCameraScreen.TopButton( + icon: flipButtonIcon, + iconRotationAngle: parent.iconAngle, + action: changeMirrorOutput + ) + }} + @ViewBuilder func createFlashButton() -> some View { if isFlashButtonActive { + DefaultCameraScreen.TopButton( + icon: flashButtonIcon, + iconRotationAngle: parent.iconAngle, + action: changeFlashMode + ) + }} +} + +private extension DefaultCameraScreen.TopBar { + func changeGridVisibility() { + parent.setGridVisibility(!parent.isGridVisible) + } + func changeMirrorOutput() { + parent.setMirrorOutput(!parent.isOutputMirrored) + } + func changeFlashMode() { + parent.setFlashMode(parent.flashMode.next()) + } +} + +private extension DefaultCameraScreen.TopBar { + var topPadding: CGFloat { switch parent.deviceOrientation { + case .portrait, .portraitUpsideDown: return 40 + default: return 20 + }} +} +private extension DefaultCameraScreen.TopBar { + var gridButtonIcon: ImageResource { switch parent.isGridVisible { + case true: .mijickIconGridOn + case false: .mijickIconGridOff + }} + var flipButtonIcon: ImageResource { switch parent.isOutputMirrored { + case true: .mijickIconFlipOn + case false: .mijickIconFlipOff + }} + var flashButtonIcon: ImageResource { switch parent.flashMode { + case .off: .mijickIconFlashOff + case .on: .mijickIconFlashOn + case .auto: .mijickIconFlashAuto + }} +} +private extension DefaultCameraScreen.TopBar { + var isTopBarActive: Bool { parent.cameraManager.captureSession.isRunning } + var isCloseButtonActive: Bool { parent.config.closeButtonAllowed && !parent.isRecording } + var isCentralViewActive: Bool { parent.isRecording } + var isRightSideViewActive: Bool { !parent.isRecording } + var isGridButtonActive: Bool { parent.config.gridButtonAllowed } + var isFlipOutputButtonActive: Bool { parent.config.flipButtonAllowed && parent.cameraPosition == .front } + var isFlashButtonActive: Bool { parent.config.flashButtonAllowed && parent.hasFlash && parent.cameraOutputType == .photo } +} diff --git a/Sources/Internal/UI/Default Screens/Camera/DefaultCameraScreen+TopButton.swift b/Sources/Internal/UI/Default Screens/Camera/DefaultCameraScreen+TopButton.swift new file mode 100644 index 0000000..bf3037b --- /dev/null +++ b/Sources/Internal/UI/Default Screens/Camera/DefaultCameraScreen+TopButton.swift @@ -0,0 +1,35 @@ +// +// DefaultCameraScreen+TopButton.swift of MijickCamera +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import SwiftUI + +extension DefaultCameraScreen { struct TopButton: View { + let icon: ImageResource + let iconRotationAngle: Angle + let action: () -> () + + + var body: some View { + Button(action: action, label: createButtonLabel) + } +}} +private extension DefaultCameraScreen.TopButton { + func createButtonLabel() -> some View { + Image(icon) + .resizable() + .frame(width: 16, height: 16) + .foregroundColor(Color(.mijickBackgroundInverted)) + .rotationEffect(iconRotationAngle) + .frame(width: 32, height: 32) + .background(Color(.mijickBackgroundSecondary)) + .mask(Circle()) + } +} diff --git a/Sources/Internal/UI/Default Screens/Camera/DefaultCameraScreen.swift b/Sources/Internal/UI/Default Screens/Camera/DefaultCameraScreen.swift new file mode 100644 index 0000000..3a3313a --- /dev/null +++ b/Sources/Internal/UI/Default Screens/Camera/DefaultCameraScreen.swift @@ -0,0 +1,54 @@ +// +// DefaultCameraScreen.swift of MijickCamera +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import SwiftUI + +public struct DefaultCameraScreen: MCameraScreen { + @ObservedObject public var cameraManager: CameraManager + public let namespace: Namespace.ID + public let closeMCameraAction: () -> () + var config: Config = .init() + + + public var body: some View { + ZStack { + createContentView() + createTopBar() + createBottomBar() + } + .ignoresSafeArea() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(.mijickBackgroundPrimary).ignoresSafeArea()) + .statusBarHidden() + .animation(.mSpring) + } +} +private extension DefaultCameraScreen { + func createTopBar() -> some View { + DefaultCameraScreen.TopBar(parent: self) + .frame(maxHeight: .infinity, alignment: .top) + } + func createContentView() -> some View { + createCameraOutputView() + .ignoresSafeArea() + } + func createBottomBar() -> some View { + DefaultCameraScreen.BottomBar(parent: self) + .frame(maxHeight: .infinity, alignment: .bottom) + } +} + +extension DefaultCameraScreen { + var iconAngle: Angle { switch isOrientationLocked { + case true: deviceOrientation.getAngle() + case false: .zero + }} +} diff --git a/Sources/Internal/UI/Default Screens/Captured Media/DefaultCapturedMediaScreen.swift b/Sources/Internal/UI/Default Screens/Captured Media/DefaultCapturedMediaScreen.swift new file mode 100644 index 0000000..1c3fbfa --- /dev/null +++ b/Sources/Internal/UI/Default Screens/Captured Media/DefaultCapturedMediaScreen.swift @@ -0,0 +1,90 @@ +// +// DefaultCapturedMediaScreen.swift of MijickCamera +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import SwiftUI +import AVKit + +struct DefaultCapturedMediaScreen: MCapturedMediaScreen { + let capturedMedia: MCameraMedia + let namespace: Namespace.ID + let retakeAction: () -> () + let acceptMediaAction: () -> () + @State private var player: AVPlayer = .init() + @State private var isInitialized: Bool = false + + + var body: some View { + ZStack { + createContentView() + createButtons() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(.mijickBackgroundPrimary).ignoresSafeArea()) + .animation(.mSpring, value: isInitialized) + .onAppear { isInitialized = true } + } +} +private extension DefaultCapturedMediaScreen { + @ViewBuilder func createContentView() -> some View { if isInitialized { + if let image = capturedMedia.getImage() { createImageView(image) } + else if let video = capturedMedia.getVideo() { createVideoView(video) } + }} + func createButtons() -> some View { + HStack(spacing: 32) { + createRetakeButton() + createSaveButton() + } + .padding(.top, 12) + .padding(.bottom, 4) + .frame(maxHeight: .infinity, alignment: .bottom) + .padding(.bottom, 8) + } +} +private extension DefaultCapturedMediaScreen { + func createImageView(_ image: UIImage) -> some View { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + .ignoresSafeArea() + .transition(.scale(scale: 1.1)) + } + func createVideoView(_ video: URL) -> some View { + VideoPlayer(player: player) + .onAppear { onVideoAppear(video) } + } + @ViewBuilder func createRetakeButton() -> some View { if isInitialized { + BottomButton( + icon: .mijickIconCancel, + iconColor: .init(.mijickBackgroundInverted), + backgroundColor: .init(.mijickBackgroundSecondary), + rotationAngle: .zero, + action: retakeAction + ) + .transition(.scale) + }} + @ViewBuilder func createSaveButton() -> some View { if isInitialized { + BottomButton( + icon: .mijickIconCheck, + iconColor: .init(.mijickBackgroundPrimary), + backgroundColor: .init(.mijickBackgroundInverted), + rotationAngle: .zero, + action: acceptMediaAction + ) + .transition(.scale) + }} +} + +private extension DefaultCapturedMediaScreen { + func onVideoAppear(_ url: URL) { + player = .init(url: url) + player.play() + } +} diff --git a/Sources/Internal/UI/Default Screens/Common Components/DefaultScreen+BottomButton.swift b/Sources/Internal/UI/Default Screens/Common Components/DefaultScreen+BottomButton.swift new file mode 100644 index 0000000..db3a1c1 --- /dev/null +++ b/Sources/Internal/UI/Default Screens/Common Components/DefaultScreen+BottomButton.swift @@ -0,0 +1,37 @@ +// +// DefaultScreen+BottomButton.swift of MijickCamera +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import SwiftUI + +struct BottomButton: View { + let icon: ImageResource + let iconColor: Color + let backgroundColor: Color + let rotationAngle: Angle + let action: () -> () + + + var body: some View { + Button(action: action, label: createButtonLabel).buttonStyle(ButtonScaleStyle()) + } +} +private extension BottomButton { + func createButtonLabel() -> some View { + Image(icon) + .resizable() + .frame(width: 26, height: 26) + .foregroundColor(iconColor) + .rotationEffect(rotationAngle) + .frame(width: 52, height: 52) + .background(backgroundColor) + .mask(Circle()) + } +} diff --git a/Sources/Internal/UI/Default Screens/Common Components/DefaultScreen+CloseButton.swift b/Sources/Internal/UI/Default Screens/Common Components/DefaultScreen+CloseButton.swift new file mode 100644 index 0000000..7b7746e --- /dev/null +++ b/Sources/Internal/UI/Default Screens/Common Components/DefaultScreen+CloseButton.swift @@ -0,0 +1,29 @@ +// +// DefaultScreen+CloseButton.swift of MijickCamera +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import SwiftUI + +struct CloseButton: View { + let action: () -> () + + + var body: some View { + Button(action: action, label: createButtonLabel) + } +} +private extension CloseButton { + func createButtonLabel() -> some View { + Image(.mijickIconCancel) + .resizable() + .frame(width: 24, height: 24) + .foregroundColor(Color(.mijickBackgroundInverted)) + } +} diff --git a/Sources/Internal/UI/Default Screens/Error/DefaultCameraErrorScreen.swift b/Sources/Internal/UI/Default Screens/Error/DefaultCameraErrorScreen.swift new file mode 100644 index 0000000..1f3aaa2 --- /dev/null +++ b/Sources/Internal/UI/Default Screens/Error/DefaultCameraErrorScreen.swift @@ -0,0 +1,79 @@ +// +// DefaultCameraErrorScreen.swift of MijickCamera +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import SwiftUI + +struct DefaultCameraErrorScreen: MCameraErrorScreen { + let error: MCameraError + let closeMCameraAction: () -> () + + + var body: some View { + VStack(spacing: 0) { + Spacer().frame(height: 8) + createCloseButton() + Spacer() + createTitle() + Spacer().frame(height: 16) + createDescription() + Spacer().frame(height: 32) + createOpenSettingsButton() + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(.mijickBackgroundPrimary).ignoresSafeArea()) + } +} +private extension DefaultCameraErrorScreen { + func createCloseButton() -> some View { + CloseButton(action: closeMCameraAction) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, 20) + } + func createTitle() -> some View { + Text(title) + .font(.system(size: 20, weight: .bold)) + .foregroundColor(.init(.mijickTextPrimary)) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 64) + } + func createDescription() -> some View { + Text(description) + .font(.system(size: 16, weight: .regular)) + .foregroundColor(.init(.mijickTextSecondary)) + .lineSpacing(4) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 32) + } + func createOpenSettingsButton() -> some View { + Button(action: openAppSettings) { + Text(openSettingsButton) + .font(.system(size: 16, weight: .bold)) + .foregroundColor(Color(.mijickTextBrand)) + } + } +} + +private extension DefaultCameraErrorScreen { + var title: String { switch error { + case .microphonePermissionsNotGranted: NSLocalizedString("Enable Microphone Access", comment: "") + case .cameraPermissionsNotGranted: NSLocalizedString("Enable Camera Access", comment: "") + default: "" + }} + var description: String { switch error { + case .microphonePermissionsNotGranted: Bundle.main.infoDictionary?["NSMicrophoneUsageDescription"] as? String ?? "" + case .cameraPermissionsNotGranted: Bundle.main.infoDictionary?["NSCameraUsageDescription"] as? String ?? "" + default: "" + }} + var openSettingsButton: String { NSLocalizedString("Open Settings", comment: "") } +} diff --git a/Sources/Internal/UI/MCamera/MCamera+Config.swift b/Sources/Internal/UI/MCamera/MCamera+Config.swift new file mode 100644 index 0000000..3e1f3b9 --- /dev/null +++ b/Sources/Internal/UI/MCamera/MCamera+Config.swift @@ -0,0 +1,28 @@ +// +// MCamera+Config.swift of MijickCamera +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import SwiftUI + +extension MCamera { @MainActor class Config { + // MARK: Screens + var cameraScreen: CameraScreenBuilder = DefaultCameraScreen.init + var capturedMediaScreen: CapturedMediaScreenBuilder? = DefaultCapturedMediaScreen.init + var errorScreen: ErrorScreenBuilder = DefaultCameraErrorScreen.init + + // MARK: Actions + var imageCapturedAction: (UIImage, MCamera.Controller) -> () = { _,_ in } + var videoCapturedAction: (URL, MCamera.Controller) -> () = { _,_ in } + var closeMCameraAction: () -> () = {} + + // MARK: Others + var appDelegate: MApplicationDelegate.Type? = nil + var isCameraConfigured: Bool = false +}} diff --git a/Sources/Internal/UI/MCamera/MCamera+Controller.swift b/Sources/Internal/UI/MCamera/MCamera+Controller.swift new file mode 100644 index 0000000..5c07b57 --- /dev/null +++ b/Sources/Internal/UI/MCamera/MCamera+Controller.swift @@ -0,0 +1,16 @@ +// +// MCamera+Controller.swift of MijickCamera +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import Foundation + +extension MCamera { @MainActor public struct Controller { + let mCamera: MCamera +}} diff --git a/Sources/Internal/UI/MCamera/MCamera.swift b/Sources/Internal/UI/MCamera/MCamera.swift new file mode 100644 index 0000000..b807377 --- /dev/null +++ b/Sources/Internal/UI/MCamera/MCamera.swift @@ -0,0 +1,181 @@ +// +// MCamera.swift of MijickCamera +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import SwiftUI + +/** + A view that displays a camera with state-specific screens. + + By default, it includes three screens that change depending on the status of the camera; **Error Screen**, **Camera Screen** and **Captured Media Screen**. + + Handles issues related to asking for permissions, and if permissions are not granted, it displays the **Error Screen**. + + Optionally shows the **Captured Media Screen**, which is displayed after the user captures an image or video. + + + # Customization + All of the MCamera's default settings can be changed during initialisation. + - important: To start a camera session, simply call the ``startSession()`` method. For more details, see the **Usage** section. + + ## Camera Screens + Use one of the methods below to change the default screens: + - ``setCameraScreen(_:)`` + - ``setCapturedMediaScreen(_:)`` + - ``setErrorScreen(_:)`` + + - tip: To disable displaying captured media, call the ``setCapturedMediaScreen(_:)`` method with a nil value. + + ## Actions after capturing media + Use one of the methods below to set actions that will be called after capturing media: + - ``onImageCaptured(_:)`` + - ``onVideoCaptured(_:)`` + - note: If there is no **Captured Media Screen**, the action is called immediately after the media is captured, otherwise it is triggered after the user accepts the captured media in the **Captured Media Screen**. + + ## Camera Configuration + To change the initial camera settings, use the following methods: + - ``setCameraOutputType(_:)`` + - ``setCameraPosition(_:)`` + - ``setAudioAvailability(_:)`` + - ``setZoomFactor(_:)`` + - ``setFlashMode(_:)`` + - ``setLightMode(_:)`` + - ``setResolution(_:)`` + - ``setFrameRate(_:)`` + - ``setCameraExposureDuration(_:)`` + - ``setCameraTargetBias(_:)`` + - ``setCameraISO(_:)`` + - ``setCameraExposureMode(_:)`` + - ``setCameraHDRMode(_:)`` + - ``setCameraFilters(_:)`` + - ``setMirrorOutput(_:)`` + - ``setGridVisibility(_:)`` + - ``setFocusImage(_:)`` + - ``setFocusImageColor(_:)`` + - ``setFocusImageSize(_:)`` + - important: Note that if you try to set a value that exceeds the camera's capabilities, the camera will automatically set the closest possible value and show you which value has been set. + + ## Other + There are other methods that you can use to customize your experience: + - ``setCloseMCameraAction(_:)`` + - ``lockCameraInPortraitOrientation(_:)`` + + # Usage + ```swift + struct ContentView: View { + var body: some View { + MCamera() + .setCameraFilters([.init(name: "CISepiaTone")!]) + .setCameraPosition(.back) + .setCameraOutputType(.video) + .setAudioAvailability(false) + .setResolution(.hd4K3840x2160) + .setFrameRate(30) + .setZoomFactor(1.2) + .setCameraISO(3) + .setCameraTargetBias(1.2) + .setLightMode(.on) + .setFlashMode(.auto) + + // MUST BE CALLED! + .startSession() + } + } + ``` + */ +public struct MCamera: View { + @ObservedObject var manager: CameraManager + @Namespace var namespace + var config: Config = .init() + + + public var body: some View { if config.isCameraConfigured { + ZStack(content: createContent) + .onDisappear(perform: onDisappear) + .onChange(of: manager.attributes.capturedMedia, perform: onCapturedMediaChange) + }} +} +private extension MCamera { + @ViewBuilder func createContent() -> some View { + if let error = manager.attributes.error { createErrorScreen(error) } + else if let capturedMedia = manager.attributes.capturedMedia, config.capturedMediaScreen != nil { createCapturedMediaScreen(capturedMedia) } + else { createCameraScreen() } + } +} +private extension MCamera { + func createErrorScreen(_ error: MCameraError) -> some View { + config.errorScreen(error, config.closeMCameraAction).erased() + } + func createCapturedMediaScreen(_ media: MCameraMedia) -> some View { + config.capturedMediaScreen?(media, namespace, onCapturedMediaRejected, onCapturedMediaAccepted) + .erased() + .onAppear(perform: onCaptureMediaScreenAppear) + } + func createCameraScreen() -> some View { + config.cameraScreen(manager, namespace, config.closeMCameraAction) + .erased() + .onAppear(perform: onCameraAppear) + .onDisappear(perform: onCameraDisappear) + } +} + + +// MARK: - ACTIONS + + + +// MARK: MCamera +private extension MCamera { + func onDisappear() { + lockScreenOrientation(nil) + manager.cancel() + } + func onCapturedMediaChange(_ capturedMedia: MCameraMedia?) { + guard let capturedMedia, config.capturedMediaScreen == nil else { return } + notifyUserOfMediaCaptured(capturedMedia) + } +} +private extension MCamera { + func lockScreenOrientation(_ orientation: UIInterfaceOrientationMask?) { + config.appDelegate?.orientationLock = orientation ?? .all + UINavigationController.attemptRotationToDeviceOrientation() + } + func notifyUserOfMediaCaptured(_ capturedMedia: MCameraMedia) { + if let image = capturedMedia.getImage() { config.imageCapturedAction(image, .init(mCamera: self)) } + else if let video = capturedMedia.getVideo() { config.videoCapturedAction(video, .init(mCamera: self)) } + } +} + +// MARK: Camera Screen +private extension MCamera { + func onCameraAppear() { Task { + do { + try await manager.setup() + lockScreenOrientation(.portrait) + } catch { print("(MijickCamera) ERROR DURING SETUP: \(error)") } + }} + func onCameraDisappear() { + manager.cancel() + } +} + +// MARK: Captured Media Screen +private extension MCamera { + func onCaptureMediaScreenAppear() { + lockScreenOrientation(nil) + } + func onCapturedMediaRejected() { + manager.setCapturedMedia(nil) + } + func onCapturedMediaAccepted() { + guard let capturedMedia = manager.attributes.capturedMedia else { return } + notifyUserOfMediaCaptured(capturedMedia) + } +} diff --git a/Sources/Public/Camera Settings/Public+CameraSettings+MApplicationDelegate.swift b/Sources/Public/Camera Settings/Public+CameraSettings+MApplicationDelegate.swift new file mode 100644 index 0000000..5522624 --- /dev/null +++ b/Sources/Public/Camera Settings/Public+CameraSettings+MApplicationDelegate.swift @@ -0,0 +1,53 @@ +// +// Public+CameraSettings+MApplicationDelegate.swift of MijickCamera +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import SwiftUI + +/** + Locks the screen in portrait mode when the Camera Screen is active. + + See ``MCamera/lockCameraInPortraitOrientation(_:)`` for more details. + - note: Blocks the rotation of the entire screen on which the **MCamera** is located. + + ## Usage + ```swift + @main struct App_Main: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + + var body: some Scene { + WindowGroup(content: ContentView.init) + } + } + +// MARK: App Delegate + class AppDelegate: NSObject, MApplicationDelegate { + static var orientationLock = UIInterfaceOrientationMask.all + + func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask { AppDelegate.orientationLock } + } + +// MARK: Content View + struct ContentView: View { + var body: some View { + MCamera() + .lockCameraInPortraitOrientation(AppDelegate.self) + + // MUST BE CALLED! + .startSession() + } + } + ``` + */ +public protocol MApplicationDelegate: UIApplicationDelegate { + static var orientationLock: UIInterfaceOrientationMask { get set } + + func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask +} diff --git a/Sources/Public/Camera Settings/Public+CameraSettings+MCamera.swift b/Sources/Public/Camera Settings/Public+CameraSettings+MCamera.swift new file mode 100644 index 0000000..a46d7a7 --- /dev/null +++ b/Sources/Public/Camera Settings/Public+CameraSettings+MCamera.swift @@ -0,0 +1,405 @@ +// +// Public+CameraSettings+MCamera.swift of MijickCamera +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import SwiftUI +import AVKit + +// MARK: Initializer +public extension MCamera { + init() { self.init(manager: .init( + captureSession: AVCaptureSession(), + captureDeviceInputType: AVCaptureDeviceInput.self + ))} +} + + +// MARK: - METHODS + + + +// MARK: Changing Default Screens +public extension MCamera { + /** + Changes the camera screen to a selected one. + + For more details and tips on creating your own **Camera Screen**, see the ``MCameraScreen`` documentation. + + - tip: To hide selected buttons and controls on the screen, use the method with DefaultCameraScreen as argument. For a code example, please refer to Usage -> Default Camera Screen Customization section. + + + # Usage + + ## New Camera Screen + ```swift + struct ContentView: View { + var body: some View { + MCamera() + .setCameraScreen(CustomCameraScreen.init) + + // MUST BE CALLED! + .startSession() + } + } + ``` + + ## Default Camera Screen Customization + ```swift + struct ContentView: View { + var body: some View { + MCamera() + .setCameraScreen { + DefaultCameraScreen(cameraManager: $0, namespace: $1, closeMCameraAction: $2) + .captureButtonAllowed(false) + .cameraOutputSwitchAllowed(false) + .lightButtonAllowed(false) + } + + // MUST BE CALLED! + .startSession() + } + } + ``` + */ + func setCameraScreen(_ builder: @escaping CameraScreenBuilder) -> Self { config.cameraScreen = builder; return self } + + /** + Changes the captured media screen to a selected one. + + For more details and tips on creating your own **Captured Media Screen**, see the ``MCapturedMediaScreen`` documentation. + + - tip: To disable displaying captured media, call the method with a nil value. + + + # Usage + + ## New Captured Media Screen + ```swift + struct ContentView: View { + var body: some View { + MCamera() + .setCapturedMediaScreen(DefaultCapturedMediaScreen.init) + + // MUST BE CALLED! + .startSession() + } + } + ``` + + ## No Captured Media Screen + ```swift + struct ContentView: View { + var body: some View { + MCamera() + .setCapturedMediaScreen(nil) + + // MUST BE CALLED! + .startSession() + } + } + ``` + */ + func setCapturedMediaScreen(_ builder: CapturedMediaScreenBuilder?) -> Self { config.capturedMediaScreen = builder; return self } + + /** + Changes the error screen to a selected one. + + For more details and tips on creating your own **Error Screen**, see the ``MCameraErrorScreen`` documentation. + + + ## Usage + ```swift + struct ContentView: View { + var body: some View { + MCamera() + .setErrorScreen(CustomCameraErrorScreen.init) + + // MUST BE CALLED! + .startSession() + } + } + ``` + */ + func setErrorScreen(_ builder: @escaping ErrorScreenBuilder) -> Self { config.errorScreen = builder; return self } +} + +// MARK: Changing Initial Values +public extension MCamera { + /** + Changes the initial camera output type. + + For available options, please refer to the ``CameraOutputType`` documentation. + */ + func setCameraOutputType(_ cameraOutputType: CameraOutputType) -> Self { manager.attributes.outputType = cameraOutputType; return self } + + /** + Changes the initial camera position. + + For available options, please refer to the ``CameraPosition`` documentation. + + - note: If the selected camera position is not available, the camera will not be changed. + */ + func setCameraPosition(_ cameraPosition: CameraPosition) -> Self { manager.attributes.cameraPosition = cameraPosition; return self } + + /** + Definies whether the audio source is available. + + If disabled, the camera will not record audio, and will not ask for permission to access the microphone. + */ + func setAudioAvailability(_ isAvailable: Bool) -> Self { manager.attributes.isAudioSourceAvailable = isAvailable; return self } + + /** + Changes the initial camera zoom level. + + - note: If the zoom factor is out of bounds, it will be set to the closest available value. + */ + func setZoomFactor(_ zoomFactor: CGFloat) -> Self { manager.attributes.zoomFactor = zoomFactor; return self } + + /** + Changes the initial camera flash mode. + + For available options, please refer to the ``CameraFlashMode`` documentation. + + - note: If the selected flash mode is not available, the flash mode will not be changed. + */ + func setFlashMode(_ flashMode: CameraFlashMode) -> Self { manager.attributes.flashMode = flashMode; return self } + + /** + Sets the screen flash color for front camera captures. + + When taking photos with the front camera with flash enabled, the screen will illuminate + with this color to light up the subject's face. If not set, defaults to white. + + - parameter color: The UIColor to use for screen flash illumination. + */ + func setScreenFlashColor(_ color: UIColor?) -> Self { manager.attributes.screenFlashColor = color; return self } + + /** + Changes the initial light (torch / flashlight) mode. + + For available options, please refer to the ``CameraLightMode`` documentation. + + - note: If the selected light mode is not available, the light mode will not be changed. + */ + func setLightMode(_ lightMode: CameraLightMode) -> Self { manager.attributes.lightMode = lightMode; return self } + + /** + Changes the initial camera resolution. + + - important: Changing the resolution may affect the maximum frame rate that can be set. + */ + func setResolution(_ resolution: AVCaptureSession.Preset) -> Self { manager.attributes.resolution = resolution; return self } + + /** + Changes the initial camera frame rate. + + - note: Depending on the resolution of the camera and the current specifications of the device, there are some restrictions on the frame rate that can be set. + If you set a frame rate that exceeds the camera's capabilities, the library will automatically set the closest possible value and show you which value has been set (``MCameraScreen/frameRate``). + */ + func setFrameRate(_ frameRate: Int32) -> Self { manager.attributes.frameRate = frameRate; return self } + + /** + Changes the initial camera exposure duration. + + - note: If the exposure duration is out of bounds, it will be set to the closest available value. + */ + func setCameraExposureDuration(_ duration: CMTime) -> Self { manager.attributes.cameraExposure.duration = duration; return self } + + /** + Changes the initial camera target bias. + + - note: If the target bias is out of bounds, it will be set to the closest available value. + */ + func setCameraTargetBias(_ targetBias: Float) -> Self { manager.attributes.cameraExposure.targetBias = targetBias; return self } + + /** + Changes the initial camera ISO. + + - note: If the ISO is out of bounds, it will be set to the closest available value. + */ + func setCameraISO(_ iso: Float) -> Self { manager.attributes.cameraExposure.iso = iso; return self } + + /** + Changes the initial camera exposure mode. + + - note: If the exposure mode is not supported, the exposure mode will not be changed. + */ + func setCameraExposureMode(_ exposureMode: AVCaptureDevice.ExposureMode) -> Self { manager.attributes.cameraExposure.mode = exposureMode; return self } + + /** + Changes the initial camera HDR mode. + + For available options, please refer to the ``CameraHDRMode`` documentation. + */ + func setCameraHDRMode(_ hdrMode: CameraHDRMode) -> Self { manager.attributes.hdrMode = hdrMode; return self } + + /** + Changes the initial camera filters. + + - important: Setting multiple filters simultaneously can affect the performance of the camera. + */ + func setCameraFilters(_ filters: [CIFilter]) -> Self { manager.attributes.cameraFilters = filters; return self } + + /** + Changes the initial mirror output setting. + */ + func setMirrorOutput(_ shouldMirror: Bool) -> Self { manager.attributes.mirrorOutput = shouldMirror; return self } + + /** + Changes the initial grid visibility setting. + */ + func setGridVisibility(_ shouldShowGrid: Bool) -> Self { manager.attributes.isGridVisible = shouldShowGrid; return self } + + /** + Changes the shape of the focus indicator visible when touching anywhere on the camera screen. + */ + func setFocusImage(_ image: UIImage) -> Self { manager.cameraMetalView.focusIndicator.image = image; return self } + + /** + Changes the color of the focus indicator visible when touching anywhere on the camera screen. + */ + func setFocusImageColor(_ color: UIColor) -> Self { manager.cameraMetalView.focusIndicator.tintColor = color; return self } + + /** + Changes the size of the focus indicator visible when touching anywhere on the camera. + */ + func setFocusImageSize(_ size: CGFloat) -> Self { manager.cameraMetalView.focusIndicator.size = size; return self } +} + +// MARK: Actions +public extension MCamera { + /** + Indicates how the MCamera can be closed. + + ## Usage + ```swift + struct ContentView: View { + @State private var isSheetPresented: Bool = false + + + var body: some View { + Button(action: { isSheetPresented = true }) { + Text("Click me!") + } + .fullScreenCover(isPresented: $isSheetPresented) { + MCamera() + .setResolution(.hd1920x1080) + .setCloseMCameraAction { isSheetPresented = false } + + // MUST BE CALLED! + .startSession() + } + } + } + ``` + */ + func setCloseMCameraAction(_ action: @escaping () -> ()) -> Self { config.closeMCameraAction = action; return self } + + /** + Defines action that is called when an image is captured. + + MCameraController can be used to perform additional actions related to MCamera, such as closing MCamera or returning to the camera screen. + See ``Controller`` for more information. + + - note: The action is called immediately if **Captured Media Screen** is nil, otherwise after the user accepts the photo. + + + ## Usage + ```swift + struct ContentView: View { + var body: some View { + MCamera() + .onImageCaptured { image, controller in + saveImageInGallery(image) + controller.reopenCameraScreen() + } + + // MUST BE CALLED! + .startSession() + } + } + ``` + */ + func onImageCaptured(_ action: @escaping (UIImage, MCamera.Controller) -> ()) -> Self { config.imageCapturedAction = action; return self } + + /** + Defines action that is called when a video is captured. + + MCameraController can be used to perform additional actions related to MCamera, such as closing MCamera or returning to the camera screen. + See ``Controller`` for more information. + + - note: The action is called immediately if **Captured Media Screen** is nil, otherwise after the user accepts the video. + + + ## Usage + ```swift + struct ContentView: View { + var body: some View { + MCamera() + .onVideoCaptured { video, controller in + saveVideoInGallery(video) + controller.reopenCameraScreen() + } + + // MUST BE CALLED! + .startSession() + } + } + ``` + */ + func onVideoCaptured(_ action: @escaping (URL, MCamera.Controller) -> ()) -> Self { config.videoCapturedAction = action; return self } +} + +// MARK: Others +public extension MCamera { + /** + Locks the screen in portrait mode when the Camera Screen is active. + + See ``MApplicationDelegate`` for more details. + - note: Blocks the rotation of the entire screen on which the **MCamera** is located. + + ## Usage + ```swift + @main struct App_Main: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + + var body: some Scene { + WindowGroup(content: ContentView.init) + } + } + + // MARK: App Delegate + class AppDelegate: NSObject, MApplicationDelegate { + static var orientationLock = UIInterfaceOrientationMask.all + + func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask { AppDelegate.orientationLock } + } + + // MARK: Content View + struct ContentView: View { + var body: some View { + MCamera() + .lockCameraInPortraitOrientation(AppDelegate.self) + + // MUST BE CALLED! + .startSession() + } + } + ``` + */ + func lockCameraInPortraitOrientation(_ appDelegate: MApplicationDelegate.Type) -> Self { config.appDelegate = appDelegate; manager.attributes.orientationLocked = true; return self } + + /** + Starts the camera session. + + - important: This method must be called to start the camera. + */ + func startSession() -> some View { config.isCameraConfigured = true; return self } +} diff --git a/Sources/Public/Camera Settings/Public+CameraSettings+MCameraController.swift b/Sources/Public/Camera Settings/Public+CameraSettings+MCameraController.swift new file mode 100644 index 0000000..015b828 --- /dev/null +++ b/Sources/Public/Camera Settings/Public+CameraSettings+MCameraController.swift @@ -0,0 +1,27 @@ +// +// Public+CameraSettings+MCameraController.swift of MijickCamera +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import Foundation + +// MARK: Available Actions +public extension MCamera.Controller { + /** + Closes the MCamera. + + See ``MCamera/setCloseMCameraAction(_:)`` for more details. + */ + func closeMCamera() { mCamera.config.closeMCameraAction() } + + /** + Opens the Camera Screen. + */ + func reopenCameraScreen() { mCamera.manager.setCapturedMedia(nil) } +} diff --git a/Sources/Public/Models/Public+Model+CameraUtilities.swift b/Sources/Public/Models/Public+Model+CameraUtilities.swift new file mode 100644 index 0000000..54e18d8 --- /dev/null +++ b/Sources/Public/Models/Public+Model+CameraUtilities.swift @@ -0,0 +1,44 @@ +// +// Public+Model+CameraUtilities.swift of MijickCamera +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import SwiftUI + +// MARK: Camera Output Type +public enum CameraOutputType: CaseIterable { + case photo + case video +} + +// MARK: Camera Position +public enum CameraPosition: CaseIterable { + case back + case front +} + +// MARK: Camera Flash Mode +public enum CameraFlashMode: CaseIterable { + case off + case on + case auto +} + +// MARK: Camera Light Mode +public enum CameraLightMode: CaseIterable { + case off + case on +} + +// MARK: Camera HDR Mode +public enum CameraHDRMode: CaseIterable { + case off + case on + case auto +} diff --git a/Sources/Public/Models/Public+Model+MCameraError.swift b/Sources/Public/Models/Public+Model+MCameraError.swift new file mode 100644 index 0000000..49c5d17 --- /dev/null +++ b/Sources/Public/Models/Public+Model+MCameraError.swift @@ -0,0 +1,17 @@ +// +// Public+Model+MCameraError.swift of MijickCamera +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import Foundation + +public enum MCameraError: Error { + case microphonePermissionsNotGranted, cameraPermissionsNotGranted + case cannotSetupInput, cannotSetupOutput, cannotSetupMetalDevice +} diff --git a/Sources/Public/Models/Public+Model+MCameraMedia.swift b/Sources/Public/Models/Public+Model+MCameraMedia.swift new file mode 100644 index 0000000..16ceecb --- /dev/null +++ b/Sources/Public/Models/Public+Model+MCameraMedia.swift @@ -0,0 +1,25 @@ +// +// Public+Model+MCameraMedia.swift of MijickCamera +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import SwiftUI + +// MARK: Getters +public extension MCameraMedia { + /** + Gets the image from the media object. + */ + func getImage() -> UIImage? { image } + + /** + Gets the video URL from the media object. + */ + func getVideo() -> URL? { video } +} diff --git a/Sources/Public/UI/Public+UI+DefaultCameraScreen.swift b/Sources/Public/UI/Public+UI+DefaultCameraScreen.swift new file mode 100644 index 0000000..a9b8820 --- /dev/null +++ b/Sources/Public/UI/Public+UI+DefaultCameraScreen.swift @@ -0,0 +1,31 @@ +// +// Public+UI+DefaultCameraScreen.swift of MijickCamera +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import SwiftUI + +// MARK: Initializer +public extension DefaultCameraScreen { + init(cameraManager: CameraManager, namespace: Namespace.ID, closeMCameraAction: @escaping () -> Void) { + self.init(cameraManager: cameraManager, namespace: namespace, closeMCameraAction: closeMCameraAction, config: .init()) + } +} + +// MARK: Methods +public extension DefaultCameraScreen { + func captureButtonAllowed(_ value: Bool) -> Self { config.captureButtonAllowed = value; return self } + func cameraOutputSwitchAllowed(_ value: Bool) -> Self { config.cameraOutputSwitchAllowed = value; return self } + func cameraPositionButtonAllowed(_ value: Bool) -> Self { config.cameraPositionButtonAllowed = value; return self } + func flashButtonAllowed(_ value: Bool) -> Self { config.flashButtonAllowed = value; return self } + func lightButtonAllowed(_ value: Bool) -> Self { config.lightButtonAllowed = value; return self } + func flipButtonAllowed(_ value: Bool) -> Self { config.flipButtonAllowed = value; return self } + func gridButtonAllowed(_ value: Bool) -> Self { config.gridButtonAllowed = value; return self } + func closeButtonAllowed(_ value: Bool) -> Self { config.closeButtonAllowed = value; return self } +} diff --git a/Sources/Public/UI/Public+UI+MCameraErrorScreen.swift b/Sources/Public/UI/Public+UI+MCameraErrorScreen.swift new file mode 100644 index 0000000..35c2db3 --- /dev/null +++ b/Sources/Public/UI/Public+UI+MCameraErrorScreen.swift @@ -0,0 +1,54 @@ +// +// Public+UI+MCameraErrorScreen.swift of MijickCamera +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import SwiftUI + +/** + Screen that displays an error message if one or more camera permissions are denied by the user. + + - important: A view conforming to **MCameraErrorScreen** has to be passed directly to ``MCamera``. See ``MCamera/setErrorScreen(_:)`` for more details. + + + ## Usage + ```swift + struct ContentView: View { + var body: some View { + MCamera() + .setErrorScreen(CustomCameraErrorScreen.init) + + // MUST BE CALLED! + .startSession() + } + } + + // MARK: Custom Camera Error Screen + struct CustomCameraErrorScreen: MCameraErrorScreen { + let error: MCameraError + let closeMCameraAction: () -> () + + + var body: some View { + Button(action: openAppSettings) { Text("Open Settings") } + } + } + ``` + */ +public protocol MCameraErrorScreen: View { + var error: MCameraError { get } + var closeMCameraAction: () -> () { get } +} + +// MARK: Methods +public extension MCameraErrorScreen { + func openAppSettings() { if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + }} +} diff --git a/Sources/Public/UI/Public+UI+MCameraScreen.swift b/Sources/Public/UI/Public+UI+MCameraScreen.swift new file mode 100644 index 0000000..5bb3313 --- /dev/null +++ b/Sources/Public/UI/Public+UI+MCameraScreen.swift @@ -0,0 +1,243 @@ +// +// Public+UI+MCameraScreen.swift of MijickCamera +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import SwiftUI +import AVFoundation +import MijickTimer + +/** + Screen that displays the camera view and manages camera actions. + + - important: A view conforming to **MCameraScreen** has to be passed directly to ``MCamera``. See ``MCamera/setCameraScreen(_:)`` for more details. + + ## Usage + ```swift + struct ContentView: View { + var body: some View { + MCamera() + .setCameraScreen(CustomCameraErrorScreen.init) + + // MUST BE CALLED! + .startSession() + } + } + + // MARK: Custom Camera Screen + struct CustomCameraScreen: MCameraScreen { + @ObservedObject var cameraManager: CameraManager + let namespace: Namespace.ID + let closeMCameraAction: () -> () + + + var body: some View { + VStack(spacing: 0) { + createNavigationBar() + createCameraOutputView() + createCaptureButton() + } + } + } + private extension CustomCameraScreen { + func createNavigationBar() -> some View { + Text("This is a Custom Camera View") + .padding(.top, 12) + .padding(.bottom, 12) + } + func createCaptureButton() -> some View { + Button(action: captureOutput) { Text("Click to capture") } + .padding(.top, 12) + .padding(.bottom, 12) + } + } + ``` + */ +public protocol MCameraScreen: View { + var cameraManager: CameraManager { get } + var namespace: Namespace.ID { get } + var closeMCameraAction: () -> () { get } +} + +// MARK: Methods +public extension MCameraScreen { + /** + View that displays the camera output. + + ## Usage + ```swift + struct CustomCameraScreen: MCameraScreen { + @ObservedObject var cameraManager: CameraManager + let namespace: Namespace.ID + let closeMCameraAction: () -> () + + + var body: some View { + (...) + createCameraOutputView() + (...) + } + } + ``` + */ + func createCameraOutputView() -> some View { CameraBridgeView(cameraManager: cameraManager).equatable() } +} +public extension MCameraScreen { + /** + Capture the current camera output. + + The output type depends on what ``cameraOutputType`` is set to. + */ + func captureOutput() { cameraManager.captureOutput() } + + /** + Set the output type of the camera. + + For available options, please refer to the ``CameraOutputType`` documentation. + */ + func setOutputType(_ outputType: CameraOutputType) { cameraManager.setOutputType(outputType) } + + /** + Set the camera position. + + For available options, please refer to the ``CameraPosition`` documentation. + + - note: If the selected camera position is not available, the camera will not be changed. + */ + func setCameraPosition(_ cameraPosition: CameraPosition) async throws { try await cameraManager.setCameraPosition(cameraPosition) } + + /** + Set the zoom factor of the camera. + + - note: If the zoom factor is out of bounds, it will be set to the closest available value. + */ + func setZoomFactor(_ zoomFactor: CGFloat) throws { try cameraManager.setCameraZoomFactor(zoomFactor) } + + /** + Set the flash mode of the camera. + + For available options, please refer to the ``CameraFlashMode`` documentation. + + - note: If the selected flash mode is not available, the flash mode will not be changed. + */ + func setFlashMode(_ flashMode: CameraFlashMode) { cameraManager.setFlashMode(flashMode) } + + /** + Set the screen flash color for front camera captures. + + When taking photos with the front camera with flash enabled, the screen will illuminate + with this color to light up the subject's face. If not set, defaults to white. + + - parameter color: The UIColor to use for screen flash illumination. Pass nil for white. + */ + func setScreenFlashColor(_ color: UIColor?) { cameraManager.setScreenFlashColor(color) } + + /** + Set the light mode of the camera. + + For available options, please refer to the ``CameraLightMode`` documentation. + + - note: If the selected light mode is not available, the light mode will not be changed. + */ + func setLightMode(_ lightMode: CameraLightMode) throws { try cameraManager.setLightMode(lightMode) } + + /** + Set the camera resolution. + + - important: Changing the resolution may affect the maximum frame rate that can be set. + */ + func setResolution(_ resolution: AVCaptureSession.Preset) { cameraManager.setResolution(resolution) } + + /** + Set the camera frame rate. + + - important: Changing the resolution may affect the maximum frame rate that can be set. + - note: If the frame rate is out of bounds, it will be set to the closest available value. + */ + func setFrameRate(_ frameRate: Int32) throws { try cameraManager.setFrameRate(frameRate) } + + /** + Set the camera exposure duration. + + - note: If the exposure duration is out of bounds, it will be set to the closest available value. + */ + func setExposureDuration(_ exposureDuration: CMTime) throws { try cameraManager.setExposureDuration(exposureDuration) } + + /** + Set the camera exposure target bias. + + - note: If the target bias is out of bounds, it will be set to the closest available value. + */ + func setExposureTargetBias(_ exposureTargetBias: Float) throws { try cameraManager.setExposureTargetBias(exposureTargetBias) } + + /** + Set the camera ISO. + + - note: If the ISO is out of bounds, it will be set to the closest available value. + */ + func setISO(_ iso: Float) throws { try cameraManager.setISO(iso) } + + /** + Set the camera exposure mode. + + - note: If the exposure mode is not supported, the exposure mode will not be changed. + */ + func setExposureMode(_ exposureMode: AVCaptureDevice.ExposureMode) throws { try cameraManager.setExposureMode(exposureMode) } + + /** + Set the camera HDR mode. + + For available options, please refer to the ``CameraHDRMode`` documentation. + */ + func setHDRMode(_ hdrMode: CameraHDRMode) throws { try cameraManager.setHDRMode(hdrMode) } + + /** + Set the camera filters to be applied to the camera output. + + - important: Setting multiple filters simultaneously can affect the performance of the camera. + */ + func setCameraFilters(_ filters: [CIFilter]) { cameraManager.setCameraFilters(filters) } + + /** + Set whether the camera output should be mirrored. + */ + func setMirrorOutput(_ shouldMirror: Bool) { cameraManager.setMirrorOutput(shouldMirror) } + + /** + Set whether the camera grid should be visible. + */ + func setGridVisibility(_ shouldShowGrid: Bool) { cameraManager.setGridVisibility(shouldShowGrid) } +} + +// MARK: Attributes +public extension MCameraScreen { + var cameraOutputType: CameraOutputType { cameraManager.attributes.outputType } + var cameraPosition: CameraPosition { cameraManager.attributes.cameraPosition } + var zoomFactor: CGFloat { cameraManager.attributes.zoomFactor } + var flashMode: CameraFlashMode { cameraManager.attributes.flashMode } + var lightMode: CameraLightMode { cameraManager.attributes.lightMode } + var resolution: AVCaptureSession.Preset { cameraManager.attributes.resolution } + var frameRate: Int32 { cameraManager.attributes.frameRate } + var exposureDuration: CMTime { cameraManager.attributes.cameraExposure.duration } + var exposureTargetBias: Float { cameraManager.attributes.cameraExposure.targetBias } + var iso: Float { cameraManager.attributes.cameraExposure.iso } + var exposureMode: AVCaptureDevice.ExposureMode { cameraManager.attributes.cameraExposure.mode } + var hdrMode: CameraHDRMode { cameraManager.attributes.hdrMode } + var cameraFilters: [CIFilter] { cameraManager.attributes.cameraFilters } + var isOutputMirrored: Bool { cameraManager.attributes.mirrorOutput } + var isGridVisible: Bool { cameraManager.attributes.isGridVisible } +} +public extension MCameraScreen { + var hasFlash: Bool { cameraManager.hasFlash } + var hasLight: Bool { cameraManager.hasLight } + var recordingTime: MTime { cameraManager.videoOutput.recordingTime } + var isRecording: Bool { cameraManager.videoOutput.timer.timerStatus == .running } + var isOrientationLocked: Bool { cameraManager.attributes.orientationLocked || cameraManager.attributes.userBlockedScreenRotation } + var deviceOrientation: AVCaptureVideoOrientation { cameraManager.attributes.deviceOrientation } +} diff --git a/Sources/Public/UI/Public+UI+MCapturedMediaScreen.swift b/Sources/Public/UI/Public+UI+MCapturedMediaScreen.swift new file mode 100644 index 0000000..1999fdd --- /dev/null +++ b/Sources/Public/UI/Public+UI+MCapturedMediaScreen.swift @@ -0,0 +1,82 @@ +// +// Public+UI+MCapturedMediaScreen.swift of MijickCamera +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import SwiftUI + +/** + Screen that displays the captured media. + + - important: A view conforming to **MCapturedMediaScreen** has to be passed directly to ``MCamera``. See ``MCamera/setCapturedMediaScreen(_:)`` for more details. + + + ## Usage + ```swift + struct ContentView: View { + var body: some View { + MCamera() + .setCapturedMediaScreen(CustomCapturedMediaScreen.init) + + // MUST BE CALLED! + .startSession() + } + } + + // MARK: Custom Captured Media Screen + struct CustomCapturedMediaScreen: MCapturedMediaScreen { + let capturedMedia: MCameraMedia + let namespace: Namespace.ID + let retakeAction: () -> () + let acceptMediaAction: () -> () + + + var body: some View { + VStack(spacing: 0) { + Spacer() + createContentView() + Spacer() + createButtons() + } + } + } + private extension CustomCapturedMediaScreen { + func createContentView() -> some View { ZStack { + if let image = capturedMedia.getImage() { createImageView(image) } + else { EmptyView() } + }} + func createButtons() -> some View { + HStack(spacing: 24) { + createRetakeButton() + createSaveButton() + } + } + } + private extension CustomCapturedMediaScreen { + func createImageView(_ image: UIImage) -> some View { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + .ignoresSafeArea() + } + func createRetakeButton() -> some View { + Button(action: retakeAction) { Text("Retake") } + } + func createSaveButton() -> some View { + Button(action: acceptMediaAction) { Text("Save") } + } + } + ``` + */ +public protocol MCapturedMediaScreen: View { + var capturedMedia: MCameraMedia { get } + var namespace: Namespace.ID { get } + var retakeAction: () -> () { get } + var acceptMediaAction: () -> () { get } +} diff --git a/Tests/Tests+CameraManager.swift b/Tests/Tests+CameraManager.swift new file mode 100644 index 0000000..075d637 --- /dev/null +++ b/Tests/Tests+CameraManager.swift @@ -0,0 +1,391 @@ +// +// Tests+CameraManager.swift of MijickCamera +// +// Created by Tomasz Kurylik. Sending ❤️ from Kraków! +// - Mail: tomasz.kurylik@mijick.com +// - GitHub: https://github.com/FulcrumOne +// - Medium: https://medium.com/@mijick +// +// Copyright ©2024 Mijick. All rights reserved. + + +import Testing +import SwiftUI +@testable import MijickCamera + +@MainActor @Suite("Camera Manager Tests") struct CameraManagerTests { + var cameraManager: CameraManager = .init( + captureSession: MockCaptureSession(), + captureDeviceInputType: MockDeviceInput.self + ) +} + +// MARK: Setup +extension CameraManagerTests { + @Test("Setup: Default Attributes") func setupWithDefaultAttributes() async throws { + try await setupCamera() + + #expect(cameraManager.captureSession.isRunning == true) + #expect(cameraManager.captureSession.deviceInputs.count == 2) + #expect(cameraManager.photoOutput.parent != nil) + #expect(cameraManager.videoOutput.parent != nil) + #expect(cameraManager.captureSession.outputs.count == 3) + #expect(cameraManager.cameraView != nil) + #expect(cameraManager.cameraLayer.isHidden == true) + #expect(cameraManager.cameraMetalView.parent != nil) + #expect(cameraManager.cameraGridView.parent != nil) + #expect(cameraManager.motionManager.manager.accelerometerUpdateInterval > 0) + #expect(cameraManager.notificationCenterManager.parent != nil) + } + @Test("Setup: Custom Attributes") func setupWithCustomAttributes() async throws { + cameraManager.attributes.cameraPosition = .front + cameraManager.attributes.zoomFactor = 2137 + cameraManager.attributes.lightMode = .on + cameraManager.attributes.resolution = .hd1280x720 + cameraManager.attributes.frameRate = 666 + cameraManager.attributes.cameraExposure.duration = .init(value: 1, timescale: 10) + cameraManager.attributes.cameraExposure.targetBias = 0.66 + cameraManager.attributes.cameraExposure.iso = 2000 + cameraManager.attributes.cameraExposure.mode = .custom + cameraManager.attributes.hdrMode = .off + cameraManager.attributes.isGridVisible = false + + try await setupCamera() + + #expect(currentDevice.uniqueID == cameraManager.frontCameraInput?.device.uniqueID) + #expect(currentDevice.videoZoomFactor == currentDevice.maxAvailableVideoZoomFactor) + #expect(currentDevice.lightMode == .on) + #expect(cameraManager.captureSession.sessionPreset == .hd1280x720) + #expect(currentDevice.activeVideoMinFrameDuration == .init(value: 1, timescale: Int32(currentDevice.maxFrameRate!))) + #expect(currentDevice.activeVideoMaxFrameDuration == .init(value: 1, timescale: Int32(currentDevice.maxFrameRate!))) + #expect(currentDevice.exposureDuration == .init(value: 1, timescale: 10)) + #expect(currentDevice.exposureTargetBias == 0.66) + #expect(currentDevice.iso == currentDevice.maxISO) + #expect(currentDevice.exposureMode == .custom) + #expect(currentDevice.hdrMode == .off) + #expect(cameraManager.cameraGridView.alpha == 0) + + #expect(cameraManager.attributes.zoomFactor == currentDevice.maxAvailableVideoZoomFactor) + #expect(cameraManager.attributes.frameRate == Int32(currentDevice.maxFrameRate!)) + #expect(cameraManager.attributes.cameraExposure.iso == currentDevice.maxISO) + } + @Test("Setup: Audio Source Unavailable") func setupWithAudioSourceUnavailable() async throws { + cameraManager.attributes.isAudioSourceAvailable = false + try await setupCamera() + + #expect(cameraManager.captureSession.deviceInputs.count == 1) + } +} + +// MARK: Cancel +extension CameraManagerTests { + @Test("Cancel Camera Session") func cancelCameraSession() async throws { + try await setupCamera() + cameraManager.cancel() + + #expect(cameraManager.captureSession.isRunning == false) + #expect(cameraManager.captureSession.deviceInputs.count == 0) + #expect(cameraManager.captureSession.outputs.count == 0) + } +} + +// MARK: Set Camera Output +extension CameraManagerTests { + @Test("Set Camera Output") func setCameraOutput() async throws { + try await setupCamera() + + cameraManager.setOutputType(.photo) + #expect(cameraManager.attributes.outputType == .photo) + + cameraManager.setOutputType(.video) + #expect(cameraManager.attributes.outputType == .video) + } +} + +// MARK: Set Camera Position +extension CameraManagerTests { + @Test("Set Camera Position") func setCameraPosition() async throws { + try await setupCamera() + + try await cameraManager.setCameraPosition(.front) + #expect(cameraManager.captureSession.deviceInputs.count == 2) + #expect(currentDevice.uniqueID == cameraManager.frontCameraInput?.device.uniqueID) + #expect(cameraManager.attributes.cameraPosition == .front) + + await Task.sleep(seconds: 0.5) + + try await cameraManager.setCameraPosition(.back) + #expect(cameraManager.captureSession.deviceInputs.count == 2) + #expect(currentDevice.uniqueID == cameraManager.backCameraInput?.device.uniqueID) + #expect(cameraManager.attributes.cameraPosition == .back) + + await Task.sleep(seconds: 0.5) + + try cameraManager.setCameraZoomFactor(3.2) + try await cameraManager.setCameraPosition(.front) + #expect(currentDevice.videoZoomFactor == 1) + #expect(cameraManager.attributes.zoomFactor == 1) + } +} + +// MARK: Set Camera Zoom +extension CameraManagerTests { + @Test("Set Camera Zoom") func setCameraZoom() async throws { + try await setupCamera() + + try cameraManager.setCameraZoomFactor(2.137) + #expect(currentDevice.videoZoomFactor == 2.137) + #expect(cameraManager.attributes.zoomFactor == 2.137) + + try cameraManager.setCameraZoomFactor(0.2137) + #expect(currentDevice.videoZoomFactor == currentDevice.minAvailableVideoZoomFactor) + #expect(cameraManager.attributes.zoomFactor == currentDevice.minAvailableVideoZoomFactor) + + try cameraManager.setCameraZoomFactor(213.7) + #expect(currentDevice.videoZoomFactor == currentDevice.maxAvailableVideoZoomFactor) + #expect(cameraManager.attributes.zoomFactor == currentDevice.maxAvailableVideoZoomFactor) + } +} + +// MARK: Set Camera Focus +extension CameraManagerTests { + @Test("Set Camera Focus") func setCameraFocus() async throws { + try await setupCamera() + + let point = CGPoint(x: 213.7, y: 21.37) + let expectedPoint = CGPoint(x: point.y / cameraManager.cameraView.frame.height, y: 1 - point.x / cameraManager.cameraView.frame.width) + + try cameraManager.setCameraFocus(at: point) + #expect(currentDevice.focusPointOfInterest == expectedPoint) + #expect(currentDevice.exposurePointOfInterest == expectedPoint) + #expect(currentDevice.focusMode == .autoFocus) + #expect(currentDevice.exposureMode == .autoExpose) + #expect(cameraManager.cameraView.subviews.filter { $0.tag == .focusIndicatorTag }.count == 1) + } +} + +// MARK: Set Flash Mode +extension CameraManagerTests { + @Test("Set Flash Mode") func setFlashMode() async throws { + try await setupCamera() + + cameraManager.setFlashMode(.on) + #expect(cameraManager.attributes.flashMode == .on) + + cameraManager.setFlashMode(.auto) + #expect(cameraManager.attributes.flashMode == .auto) + + cameraManager.setFlashMode(.off) + #expect(cameraManager.attributes.flashMode == .off) + } +} + +// MARK: Set Light Mode +extension CameraManagerTests { + @Test("Set Light Mode") func setLightMode() async throws { + try await setupCamera() + + try cameraManager.setLightMode(.on) + #expect(currentDevice.lightMode == .on) + #expect(cameraManager.attributes.lightMode == .on) + + try cameraManager.setLightMode(.off) + #expect(currentDevice.lightMode == .off) + #expect(cameraManager.attributes.lightMode == .off) + } +} + +// MARK: Set Mirror Output +extension CameraManagerTests { + @Test("Set Mirror Output") func setMirrorOutput() async throws { + try await setupCamera() + + cameraManager.setMirrorOutput(true) + #expect(cameraManager.attributes.mirrorOutput == true) + + cameraManager.setMirrorOutput(false) + #expect(cameraManager.attributes.mirrorOutput == false) + } +} + +// MARK: Set Grid Visibility +extension CameraManagerTests { + @Test("Set Grid Visibility") func setGridVisibility() async throws { + try await setupCamera() + + cameraManager.setGridVisibility(true) + #expect(cameraManager.cameraGridView.alpha == 1) + #expect(cameraManager.attributes.isGridVisible == true) + + cameraManager.setGridVisibility(false) + #expect(cameraManager.cameraGridView.alpha == 0) + #expect(cameraManager.attributes.isGridVisible == false) + } +} + +// MARK: Set Camera Filters +extension CameraManagerTests { + @Test("Set Camera Filters") func setCameraFilters() async throws { + try await setupCamera() + + cameraManager.setCameraFilters([.init(name: "CISepiaTone")!]) + #expect(cameraManager.attributes.cameraFilters.count == 1) + } +} + +// MARK: Set Exposure Mode +extension CameraManagerTests { + @Test("Set Exposure Mode") func setExposureMode() async throws { + try await setupCamera() + + try cameraManager.setExposureMode(.continuousAutoExposure) + #expect(currentDevice.exposureMode == .continuousAutoExposure) + #expect(cameraManager.attributes.cameraExposure.mode == .continuousAutoExposure) + + try cameraManager.setExposureMode(.autoExpose) + #expect(currentDevice.exposureMode == .autoExpose) + #expect(cameraManager.attributes.cameraExposure.mode == .autoExpose) + + try cameraManager.setExposureMode(.custom) + #expect(currentDevice.exposureMode == .custom) + #expect(cameraManager.attributes.cameraExposure.mode == .custom) + } +} + +// MARK: Set Exposure Duration +extension CameraManagerTests { + @Test("Set Exposure Duration") func setExposureDuration() async throws { + try await setupCamera() + + try cameraManager.setExposureDuration(.init(value: 1, timescale: 33)) + #expect(currentDevice.exposureDuration == .init(value: 1, timescale: 33)) + #expect(currentDevice.exposureMode == .custom) + #expect(cameraManager.attributes.cameraExposure.duration == .init(value: 1, timescale: 33)) + + try cameraManager.setExposureDuration(.init(value: 1, timescale: 100000)) + #expect(currentDevice.exposureDuration == currentDevice.minExposureDuration) + #expect(currentDevice.exposureMode == .custom) + #expect(cameraManager.attributes.cameraExposure.duration == currentDevice.minExposureDuration) + + try cameraManager.setExposureDuration(.init(value: 1, timescale: 2)) + #expect(currentDevice.exposureDuration == currentDevice.maxExposureDuration) + #expect(currentDevice.exposureMode == .custom) + #expect(cameraManager.attributes.cameraExposure.duration == currentDevice.maxExposureDuration) + } +} + +// MARK: Set ISO +extension CameraManagerTests { + @Test("Set ISO") func setISO() async throws { + try await setupCamera() + + try cameraManager.setISO(1) + #expect(currentDevice.iso == 1) + #expect(currentDevice.exposureMode == .custom) + #expect(cameraManager.attributes.cameraExposure.iso == 1) + + try cameraManager.setISO(-2137) + #expect(currentDevice.iso == currentDevice.minISO) + #expect(currentDevice.exposureMode == .custom) + #expect(cameraManager.attributes.cameraExposure.iso == currentDevice.minISO) + + try cameraManager.setISO(2137) + #expect(currentDevice.iso == currentDevice.maxISO) + #expect(currentDevice.exposureMode == .custom) + #expect(cameraManager.attributes.cameraExposure.iso == currentDevice.maxISO) + } +} + +// MARK: Set Exposure Target Bias +extension CameraManagerTests { + @Test("Set Exposure Target Bias") func setExposureTargetBias() async throws { + try await setupCamera() + + try cameraManager.setExposureTargetBias(1) + #expect(currentDevice.exposureTargetBias == 1) + #expect(cameraManager.attributes.cameraExposure.targetBias == 1) + + try cameraManager.setExposureTargetBias(-2137) + #expect(currentDevice.exposureTargetBias == currentDevice.minExposureTargetBias) + #expect(cameraManager.attributes.cameraExposure.targetBias == currentDevice.minExposureTargetBias) + + try cameraManager.setExposureTargetBias(2137) + #expect(currentDevice.exposureTargetBias == currentDevice.maxExposureTargetBias) + #expect(cameraManager.attributes.cameraExposure.targetBias == currentDevice.maxExposureTargetBias) + } +} + +// MARK: Set HDR Mode +extension CameraManagerTests { + @Test("Set HDR Mode") func setHDRMode() async throws { + try await setupCamera() + + try cameraManager.setHDRMode(.on) + #expect(currentDevice.hdrMode == .on) + #expect(cameraManager.attributes.hdrMode == .on) + + try cameraManager.setHDRMode(.off) + #expect(currentDevice.hdrMode == .off) + #expect(cameraManager.attributes.hdrMode == .off) + + try cameraManager.setHDRMode(.auto) + #expect(currentDevice.hdrMode == .auto) + #expect(cameraManager.attributes.hdrMode == .auto) + } +} + +// MARK: Set Resolution +extension CameraManagerTests { + @Test("Set Resolution") func setResolution() async throws { + try await setupCamera() + + cameraManager.setResolution(.hd1280x720) + #expect(cameraManager.captureSession.sessionPreset == .hd1280x720) + #expect(cameraManager.attributes.resolution == .hd1280x720) + + cameraManager.setResolution(.hd1920x1080) + #expect(cameraManager.captureSession.sessionPreset == .hd1920x1080) + #expect(cameraManager.attributes.resolution == .hd1920x1080) + + cameraManager.setResolution(.cif352x288) + #expect(cameraManager.captureSession.sessionPreset == .cif352x288) + #expect(cameraManager.attributes.resolution == .cif352x288) + } +} + +// MARK: Set Frame Rate +extension CameraManagerTests { + @Test("Set Frame Rate") func setFrameRate() async throws { + try await setupCamera() + + try cameraManager.setFrameRate(45) + #expect(currentDevice.activeVideoMinFrameDuration == .init(value: 1, timescale: 45)) + #expect(currentDevice.activeVideoMaxFrameDuration == .init(value: 1, timescale: 45)) + #expect(cameraManager.attributes.frameRate == 45) + + try cameraManager.setFrameRate(10) + #expect(currentDevice.activeVideoMinFrameDuration.timescale == Int32(currentDevice.minFrameRate!)) + #expect(currentDevice.activeVideoMaxFrameDuration.timescale == Int32(currentDevice.minFrameRate!)) + #expect(cameraManager.attributes.frameRate == Int32(currentDevice.minFrameRate!)) + + try cameraManager.setFrameRate(100) + #expect(currentDevice.activeVideoMinFrameDuration.timescale == Int32(currentDevice.maxFrameRate!)) + #expect(currentDevice.activeVideoMaxFrameDuration.timescale == Int32(currentDevice.maxFrameRate!)) + #expect(cameraManager.attributes.frameRate == Int32(currentDevice.maxFrameRate!)) + } +} + + +// MARK: Helpers +private extension CameraManagerTests { + func setupCamera() async throws { + let cameraView = UIView(frame: .init(origin: .zero, size: .init(width: 1000, height: 1000))) + + cameraManager.initialize(in: cameraView) + try await cameraManager.setup() + await Task.sleep(seconds: 10) + } +} +private extension CameraManagerTests { + var currentDevice: any CaptureDevice { cameraManager.getCameraInput()!.device } +}