From 55940f0d52a69ab1959ec80118956ddbeca085f9 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Sun, 4 Jan 2026 09:41:00 -0600 Subject: [PATCH] Initial commit of local MijickCamera package --- .github/CODEOWNERS | 1 + .github/CODE_OF_CONDUCT.md | 128 +++++ .github/CONTRIBUTING.md | 1 + .github/FUNDING.yml | 14 + .github/ISSUE_TEMPLATE/config.yml | 5 + .github/ISSUE_TEMPLATE/🚀-feature-request.md | 18 + .github/ISSUE_TEMPLATE/🦟-bug-report.md | 42 ++ .github/workflows/publish-to-cocoapods.yml | 14 + .github/workflows/run-tests.yml | 14 + .gitignore | 11 + LICENSE | 201 ++++++++ MijickCamera.podspec | 21 + Package.swift | 22 + README.md | 226 +++++++++ .../Assets/Colors.xcassets/Contents.json | 6 + .../Contents.json | 20 + .../Contents.json | 20 + .../Contents.json | 20 + .../Contents.json | 20 + .../Contents.json | 20 + .../Contents.json | 20 + .../Contents.json | 20 + .../mijick-text-brand.colorset/Contents.json | 20 + .../Contents.json | 20 + .../Contents.json | 20 + .../Contents.json | 20 + .../Assets/Icons.xcassets/Contents.json | 6 + .../mijick-icon-cancel.imageset/Contents.json | 16 + .../mijick-icon-cancel.png | Bin 0 -> 659 bytes .../Contents.json | 16 + .../mijick-icon-change-camera.png | Bin 0 -> 1415 bytes .../mijick-icon-check.imageset/Contents.json | 16 + .../mijick-icon-check.png | Bin 0 -> 634 bytes .../Contents.json | 16 + .../mijick-icon-crosshair.png | Bin 0 -> 6336 bytes .../Contents.json | 16 + .../mijick-icon-flash-auto.png | Bin 0 -> 1146 bytes .../Contents.json | 16 + .../mijick-icon-flash-off.png | Bin 0 -> 913 bytes .../Contents.json | 16 + .../mijick-icon-flash-on.png | Bin 0 -> 745 bytes .../Contents.json | 16 + .../mijick-icon-flip-off.png | Bin 0 -> 1201 bytes .../Contents.json | 16 + .../mijick-icon-flip-on.png | Bin 0 -> 914 bytes .../Contents.json | 16 + .../mijick-icon-grid-off.png | Bin 0 -> 555 bytes .../Contents.json | 16 + .../mijick-icon-grid-on.png | Bin 0 -> 521 bytes .../mijick-icon-light.imageset/Contents.json | 16 + .../mijick-icon-light.png | Bin 0 -> 692 bytes .../mijick-icon-photo.imageset/Contents.json | 16 + .../mijick-icon-photo.png | Bin 0 -> 869 bytes .../mijick-icon-video.imageset/Contents.json | 16 + .../mijick-icon-video.png | Bin 0 -> 884 bytes .../AVCaptureVideoOrientation++.swift | 46 ++ .../Extensions/AVVideoComposition++.swift | 20 + Sources/Internal/Extensions/Animation++.swift | 20 + Sources/Internal/Extensions/CIFilter++.swift | 14 + Sources/Internal/Extensions/CIImage++.swift | 24 + .../Extensions/CameraUtilities++.swift | 21 + .../Internal/Extensions/CaseIterable++.swift | 22 + .../Internal/Extensions/FileManager++.swift | 35 ++ Sources/Internal/Extensions/Task++.swift | 19 + .../Extensions/UIImage.Orientation++.swift | 26 ++ Sources/Internal/Extensions/UIView++.swift | 43 ++ Sources/Internal/Extensions/View++.swift | 17 + .../Manager/CameraManager+Attributes.swift | 40 ++ .../Manager/CameraManager+MotionManager.swift | 108 +++++ .../CameraManager+NotificationCenter.swift | 37 ++ .../CameraManager+PermissionsManager.swift | 46 ++ .../Manager/CameraManager+PhotoOutput.swift | 127 +++++ .../Manager/CameraManager+VideoOutput.swift | 158 +++++++ Sources/Internal/Manager/CameraManager.swift | 438 ++++++++++++++++++ ...tureDeviceInput+AVCaptureDeviceInput.swift | 26 ++ .../CaptureDeviceInput+MockDeviceInput.swift | 26 ++ .../CaptureDeviceInput.swift | 21 + .../CaptureDevice+AVCaptureDevice.swift | 41 ++ .../CaptureDevice+MockCaptureDevice.swift | 62 +++ .../Capture Device/CaptureDevice.swift | 131 ++++++ .../CaptureSession+AVCaptureSession.swift | 45 ++ .../CaptureSession+MockCaptureSession.swift | 57 +++ .../Capture Session/CaptureSession.swift | 27 ++ .../Internal/Miscellaneous/Typealiases.swift | 16 + Sources/Internal/Models/CameraExposure.swift | 19 + Sources/Internal/Models/MCameraMedia.swift | 28 ++ .../UI/Camera View/CameraView+Bridge.swift | 68 +++ .../CameraView+FocusIndicator.swift | 33 ++ .../UI/Camera View/CameraView+Grid.swift | 81 ++++ .../UI/Camera View/CameraView+Metal.swift | 279 +++++++++++ .../DefaultCameraScreen+BottomBar.swift | 97 ++++ ...DefaultCameraScreen+ButtonScaleStyle.swift | 19 + ...faultCameraScreen+CameraOutputSwitch.swift | 79 ++++ .../DefaultCameraScreen+CaptureButton.swift | 55 +++ .../Camera/DefaultCameraScreen+Config.swift | 23 + .../Camera/DefaultCameraScreen+TopBar.swift | 116 +++++ .../DefaultCameraScreen+TopButton.swift | 35 ++ .../Camera/DefaultCameraScreen.swift | 54 +++ .../DefaultCapturedMediaScreen.swift | 90 ++++ .../DefaultScreen+BottomButton.swift | 37 ++ .../DefaultScreen+CloseButton.swift | 29 ++ .../Error/DefaultCameraErrorScreen.swift | 79 ++++ .../Internal/UI/MCamera/MCamera+Config.swift | 28 ++ .../UI/MCamera/MCamera+Controller.swift | 16 + Sources/Internal/UI/MCamera/MCamera.swift | 181 ++++++++ ...+CameraSettings+MApplicationDelegate.swift | 53 +++ .../Public+CameraSettings+MCamera.swift | 405 ++++++++++++++++ ...lic+CameraSettings+MCameraController.swift | 27 ++ .../Models/Public+Model+CameraUtilities.swift | 44 ++ .../Models/Public+Model+MCameraError.swift | 17 + .../Models/Public+Model+MCameraMedia.swift | 25 + .../UI/Public+UI+DefaultCameraScreen.swift | 31 ++ .../UI/Public+UI+MCameraErrorScreen.swift | 54 +++ .../Public/UI/Public+UI+MCameraScreen.swift | 243 ++++++++++ .../UI/Public+UI+MCapturedMediaScreen.swift | 82 ++++ Tests/Tests+CameraManager.swift | 391 ++++++++++++++++ 116 files changed, 5705 insertions(+) create mode 100644 .github/CODEOWNERS create mode 100644 .github/CODE_OF_CONDUCT.md create mode 100644 .github/CONTRIBUTING.md create mode 100644 .github/FUNDING.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/🚀-feature-request.md create mode 100644 .github/ISSUE_TEMPLATE/🦟-bug-report.md create mode 100644 .github/workflows/publish-to-cocoapods.yml create mode 100644 .github/workflows/run-tests.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 MijickCamera.podspec create mode 100644 Package.swift create mode 100644 README.md create mode 100644 Sources/Internal/Assets/Colors.xcassets/Contents.json create mode 100644 Sources/Internal/Assets/Colors.xcassets/mijick-background-inverted.colorset/Contents.json create mode 100644 Sources/Internal/Assets/Colors.xcassets/mijick-background-primary-50.colorset/Contents.json create mode 100644 Sources/Internal/Assets/Colors.xcassets/mijick-background-primary-80.colorset/Contents.json create mode 100644 Sources/Internal/Assets/Colors.xcassets/mijick-background-primary.colorset/Contents.json create mode 100644 Sources/Internal/Assets/Colors.xcassets/mijick-background-red.colorset/Contents.json create mode 100644 Sources/Internal/Assets/Colors.xcassets/mijick-background-secondary.colorset/Contents.json create mode 100644 Sources/Internal/Assets/Colors.xcassets/mijick-background-yellow.colorset/Contents.json create mode 100644 Sources/Internal/Assets/Colors.xcassets/mijick-text-brand.colorset/Contents.json create mode 100644 Sources/Internal/Assets/Colors.xcassets/mijick-text-primary.colorset/Contents.json create mode 100644 Sources/Internal/Assets/Colors.xcassets/mijick-text-secondary.colorset/Contents.json create mode 100644 Sources/Internal/Assets/Colors.xcassets/mijick-text-tertiary.colorset/Contents.json create mode 100644 Sources/Internal/Assets/Icons.xcassets/Contents.json create mode 100644 Sources/Internal/Assets/Icons.xcassets/mijick-icon-cancel.imageset/Contents.json create mode 100644 Sources/Internal/Assets/Icons.xcassets/mijick-icon-cancel.imageset/mijick-icon-cancel.png create mode 100644 Sources/Internal/Assets/Icons.xcassets/mijick-icon-change-camera.imageset/Contents.json create mode 100644 Sources/Internal/Assets/Icons.xcassets/mijick-icon-change-camera.imageset/mijick-icon-change-camera.png create mode 100644 Sources/Internal/Assets/Icons.xcassets/mijick-icon-check.imageset/Contents.json create mode 100644 Sources/Internal/Assets/Icons.xcassets/mijick-icon-check.imageset/mijick-icon-check.png create mode 100644 Sources/Internal/Assets/Icons.xcassets/mijick-icon-crosshair.imageset/Contents.json create mode 100644 Sources/Internal/Assets/Icons.xcassets/mijick-icon-crosshair.imageset/mijick-icon-crosshair.png create mode 100644 Sources/Internal/Assets/Icons.xcassets/mijick-icon-flash-auto.imageset/Contents.json create mode 100644 Sources/Internal/Assets/Icons.xcassets/mijick-icon-flash-auto.imageset/mijick-icon-flash-auto.png create mode 100644 Sources/Internal/Assets/Icons.xcassets/mijick-icon-flash-off.imageset/Contents.json create mode 100644 Sources/Internal/Assets/Icons.xcassets/mijick-icon-flash-off.imageset/mijick-icon-flash-off.png create mode 100644 Sources/Internal/Assets/Icons.xcassets/mijick-icon-flash-on.imageset/Contents.json create mode 100644 Sources/Internal/Assets/Icons.xcassets/mijick-icon-flash-on.imageset/mijick-icon-flash-on.png create mode 100644 Sources/Internal/Assets/Icons.xcassets/mijick-icon-flip-off.imageset/Contents.json create mode 100644 Sources/Internal/Assets/Icons.xcassets/mijick-icon-flip-off.imageset/mijick-icon-flip-off.png create mode 100644 Sources/Internal/Assets/Icons.xcassets/mijick-icon-flip-on.imageset/Contents.json create mode 100644 Sources/Internal/Assets/Icons.xcassets/mijick-icon-flip-on.imageset/mijick-icon-flip-on.png create mode 100644 Sources/Internal/Assets/Icons.xcassets/mijick-icon-grid-off.imageset/Contents.json create mode 100644 Sources/Internal/Assets/Icons.xcassets/mijick-icon-grid-off.imageset/mijick-icon-grid-off.png create mode 100644 Sources/Internal/Assets/Icons.xcassets/mijick-icon-grid-on.imageset/Contents.json create mode 100644 Sources/Internal/Assets/Icons.xcassets/mijick-icon-grid-on.imageset/mijick-icon-grid-on.png create mode 100644 Sources/Internal/Assets/Icons.xcassets/mijick-icon-light.imageset/Contents.json create mode 100644 Sources/Internal/Assets/Icons.xcassets/mijick-icon-light.imageset/mijick-icon-light.png create mode 100644 Sources/Internal/Assets/Icons.xcassets/mijick-icon-photo.imageset/Contents.json create mode 100644 Sources/Internal/Assets/Icons.xcassets/mijick-icon-photo.imageset/mijick-icon-photo.png create mode 100644 Sources/Internal/Assets/Icons.xcassets/mijick-icon-video.imageset/Contents.json create mode 100644 Sources/Internal/Assets/Icons.xcassets/mijick-icon-video.imageset/mijick-icon-video.png create mode 100644 Sources/Internal/Extensions/AVCaptureVideoOrientation++.swift create mode 100644 Sources/Internal/Extensions/AVVideoComposition++.swift create mode 100644 Sources/Internal/Extensions/Animation++.swift create mode 100644 Sources/Internal/Extensions/CIFilter++.swift create mode 100644 Sources/Internal/Extensions/CIImage++.swift create mode 100644 Sources/Internal/Extensions/CameraUtilities++.swift create mode 100644 Sources/Internal/Extensions/CaseIterable++.swift create mode 100644 Sources/Internal/Extensions/FileManager++.swift create mode 100644 Sources/Internal/Extensions/Task++.swift create mode 100644 Sources/Internal/Extensions/UIImage.Orientation++.swift create mode 100644 Sources/Internal/Extensions/UIView++.swift create mode 100644 Sources/Internal/Extensions/View++.swift create mode 100644 Sources/Internal/Manager/CameraManager+Attributes.swift create mode 100644 Sources/Internal/Manager/CameraManager+MotionManager.swift create mode 100644 Sources/Internal/Manager/CameraManager+NotificationCenter.swift create mode 100644 Sources/Internal/Manager/CameraManager+PermissionsManager.swift create mode 100644 Sources/Internal/Manager/CameraManager+PhotoOutput.swift create mode 100644 Sources/Internal/Manager/CameraManager+VideoOutput.swift create mode 100644 Sources/Internal/Manager/CameraManager.swift create mode 100644 Sources/Internal/Manager/Helpers/Capture Device Input/CaptureDeviceInput+AVCaptureDeviceInput.swift create mode 100644 Sources/Internal/Manager/Helpers/Capture Device Input/CaptureDeviceInput+MockDeviceInput.swift create mode 100644 Sources/Internal/Manager/Helpers/Capture Device Input/CaptureDeviceInput.swift create mode 100644 Sources/Internal/Manager/Helpers/Capture Device/CaptureDevice+AVCaptureDevice.swift create mode 100644 Sources/Internal/Manager/Helpers/Capture Device/CaptureDevice+MockCaptureDevice.swift create mode 100644 Sources/Internal/Manager/Helpers/Capture Device/CaptureDevice.swift create mode 100644 Sources/Internal/Manager/Helpers/Capture Session/CaptureSession+AVCaptureSession.swift create mode 100644 Sources/Internal/Manager/Helpers/Capture Session/CaptureSession+MockCaptureSession.swift create mode 100644 Sources/Internal/Manager/Helpers/Capture Session/CaptureSession.swift create mode 100644 Sources/Internal/Miscellaneous/Typealiases.swift create mode 100644 Sources/Internal/Models/CameraExposure.swift create mode 100644 Sources/Internal/Models/MCameraMedia.swift create mode 100644 Sources/Internal/UI/Camera View/CameraView+Bridge.swift create mode 100644 Sources/Internal/UI/Camera View/CameraView+FocusIndicator.swift create mode 100644 Sources/Internal/UI/Camera View/CameraView+Grid.swift create mode 100644 Sources/Internal/UI/Camera View/CameraView+Metal.swift create mode 100644 Sources/Internal/UI/Default Screens/Camera/DefaultCameraScreen+BottomBar.swift create mode 100644 Sources/Internal/UI/Default Screens/Camera/DefaultCameraScreen+ButtonScaleStyle.swift create mode 100644 Sources/Internal/UI/Default Screens/Camera/DefaultCameraScreen+CameraOutputSwitch.swift create mode 100644 Sources/Internal/UI/Default Screens/Camera/DefaultCameraScreen+CaptureButton.swift create mode 100644 Sources/Internal/UI/Default Screens/Camera/DefaultCameraScreen+Config.swift create mode 100644 Sources/Internal/UI/Default Screens/Camera/DefaultCameraScreen+TopBar.swift create mode 100644 Sources/Internal/UI/Default Screens/Camera/DefaultCameraScreen+TopButton.swift create mode 100644 Sources/Internal/UI/Default Screens/Camera/DefaultCameraScreen.swift create mode 100644 Sources/Internal/UI/Default Screens/Captured Media/DefaultCapturedMediaScreen.swift create mode 100644 Sources/Internal/UI/Default Screens/Common Components/DefaultScreen+BottomButton.swift create mode 100644 Sources/Internal/UI/Default Screens/Common Components/DefaultScreen+CloseButton.swift create mode 100644 Sources/Internal/UI/Default Screens/Error/DefaultCameraErrorScreen.swift create mode 100644 Sources/Internal/UI/MCamera/MCamera+Config.swift create mode 100644 Sources/Internal/UI/MCamera/MCamera+Controller.swift create mode 100644 Sources/Internal/UI/MCamera/MCamera.swift create mode 100644 Sources/Public/Camera Settings/Public+CameraSettings+MApplicationDelegate.swift create mode 100644 Sources/Public/Camera Settings/Public+CameraSettings+MCamera.swift create mode 100644 Sources/Public/Camera Settings/Public+CameraSettings+MCameraController.swift create mode 100644 Sources/Public/Models/Public+Model+CameraUtilities.swift create mode 100644 Sources/Public/Models/Public+Model+MCameraError.swift create mode 100644 Sources/Public/Models/Public+Model+MCameraMedia.swift create mode 100644 Sources/Public/UI/Public+UI+DefaultCameraScreen.swift create mode 100644 Sources/Public/UI/Public+UI+MCameraErrorScreen.swift create mode 100644 Sources/Public/UI/Public+UI+MCameraScreen.swift create mode 100644 Sources/Public/UI/Public+UI+MCapturedMediaScreen.swift create mode 100644 Tests/Tests+CameraManager.swift 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 0000000000000000000000000000000000000000..9bc619d5761b1d930d46865d013579d0ebeb028c GIT binary patch literal 659 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7OGooCO|{#S9EO-XP4l)OOlRpde#$ zkh>GZx^prwfgF}}M_)$E)e-c?47??CXT^vIy7~kHxo%`58#PwqDN*C27%DbCq z^3_Cbjqe{oj>)=imOl z^WOQ+I+NP{#`5xSe(#)czoOm!`)_yVWAEFfYx@jD>uuJDd^_}o{fy0_{Ev@$LQD+a zJEut}=Kc8?Qm;{8XYtj*$@7mkuY|md`7eouD*r0{Oq`y`FErVg(R0Gu>DLd(C(;XN z14+q+yMH)tv3J`3u{oo><=tcUi}yI%JLO%hm1Xqm1irdk1)m!l6hoenB%klqi zHu)^a>mAc$$DfWqc#r<$z( z@wGe-SC`pu_3+14&g1L-N~HetTi(&w{v`ViyZE&KT)Prhujza*7;|WqpSRXsrxOeADcgL0F}tMxJRUP|8T5mQHnmse@OYvCig!IHiwqjK4yO? zSofs|B%?o1(w?LL!!ox&3Ve1>XMZ@#l(dLFVlUhv7}X_z#L+FouufoZ|AnGI3Rbd; z&OlOHF&IcnDklF?nAAVPvQEI$-D0wRoj_{2=f`^&bUrrEsGo20p7*SfeaFx5QY$hR z?Em3-W_R@G_q?K&oiBICW*)dw?d)3j=;L2MlSgN+w}1Q*eDO#;hqUG7Cv}hCA8xGs gT4!$z2d^*JHztIaRG#(x4NOA}p00i_>zopr0DlH39RL6T literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..6424022a18eb654d7a8175ea53078c2af1e4be96 GIT binary patch literal 1415 zcmV;21$g?2P)@~0drDELIAGL9O(c600d`2O+f$vv5yP+H*QP@?hFPZu!w-oz)b=yB!Gtqc1Zva39!SB6N`o2tY>%Z zDs@Y(?v}py>iM8?kNQ_qsic4dptK~MMgmE!O0@$*+I(P`23f*Otz(o&s} zot6bT;bW%D6Am%mPryDoem*&x7I*yoj*1Gqx97Y+;~i=>99_V-XfM#YnQkomhROph z`uskIy{7UXhH-^IwW8@l&6`YCbPuq^A6P?eL!sP|S9ouJF;A1e`nv9p-|?SoW{kh# z7Yq*@8xF!;a~MBDLz!0m^@Z;nW`?1E$nl~(-Y%@YOA7IgaGf19a)P0s_*>44-jPCl z&$Z%uvv7hTTzrl2-L+GQ?`f3RowO4SVdDEx6EZv1w9-D}bNqX4y(j4eLzwtm!f&D> zjrbjoFemi=KS|I$v+c~$k1NuN-{A;rM3Z2RzS0fDcuqR;I~w%&^F5hq(8qgA92{>~ zPP=(R>~PSacfyKH81!-fiuf4P#^DBi<1%ERWt^~+lZJ_o2MuWl+$rJE-Rr0ie0Inc z+OQm2bfIXJ)U;7l#yiNc9K5iaM&}p-O+{8L56c94L8GX|hUIc@9x_@l(6oNTa`CNO z+P#si7g#0|12f~Zoh%tqOWCde6e$vHNr2S#Hk5;=7g!>qS&KK6gJvvnnhSl1j%%U( zc5io-!~NcV5dP*F5s_lvit@0?Q6Wk;WT@fi98wtiTDpKH1W8NDca#-=PWf3V|57NA z;(%Mq<&{uwgmRSwJ{QV=qg*5hNZP589=Kb|MYEk{Fw#I{D4Rk0XhM*bO5IU5_Y6RC zfF%B)f4&gzgmQDKusR?!+NFp}BB}$1ll(z^@-%Da>`0s?N@>=lb_r)-(1M&M1W5y} zp5b?cfHw)Yl_Tv$g6u-lucY=6;?SxSAo#L97@R!U^TKo=! z&Iwj|z1ZUW^m0p>@Us!W!yr3B6M|xiZ#lebrtk06%=QiHk2&EVbRnXT_~vN7mHXKK zg5f_7)H35IeZJy8weybf!uxb}rTCs-NevM{m5sK#IW=1H3csW5EtlDZ?|wX@XbgG8 z-%yX-{?DI`E4-!h0L@Ok7@?;0(>)h)#1-A=@_sI0nGhc^Z5&|_SME>j)zSO`lUwSU z=H58M;&~+jj_QQ$II0t}GZx^prwfgF}}M_)$E)e-c?47?}7yT^vIy7~kGG>#OW2(DpFDT=<5-s~yLA z4=PSz6FnkM#4O1`!>-#pdV!-M!QQIey06UgH#Q2nRysNEtwx<+`Hp#8;}9rKsHxjyaq zkt6;Ne_WRR`tkJZoC1!7S^N6E|0DppsU7kMu87rZmlzA$_pa2n`@MrP%KT#N3%5Th zNBp;d?GjX%5Q=@(@_2UK`Tgm+XSW`SKg!wGyYFB86~EJVn{qq%clmxvw)SkE7TGen zH-5^A<9a3UTr)c3>z0Kcxu5Zib@rpVt9AEfs%|^A`@`h^_$%{NI=BvSwl#mU+x4Z* zZ1#)!Q=UKd{%a=mUwYk7@r8S)3G=w`coxkftS+HecvQcsQUu$+ zBkvz&#e-y>cH9zcti9u~?$?YX{Lhr?ti(TF-q|t#+*SGiZZ94I`Qf!ScFl@2va4$B z*b>zA9xX4fm9H20D8BLP@#pqWEB|fvGx?|gw6ePIzrnf5u*hQwd&PQg#yP#W5uLz9 P#K7R`>gTe~DWM4f^Mx98 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..cd7abd2370be4f6a168432376b010fcf0d38cc94 GIT binary patch literal 6336 zcmeHLcTiJXx8Dg!5dtV)dJz@56oWxQL7GPN5owB4iS%wzfzV4-1U0k^CWG^UlnfGwZCf&sl5j{rm0x+{oZM;;_hJ z002VwKd#;a0Iq#-bFcvb9FqrCpacqw|L#n{Td&%4YRRgnI@d|`y&R7rR%oPu zeJzv0jrFmN?~|c)QdoA-jur3b=8=5_co5N+;S;g--qb>p2Y&>N+{8pbnY3gJU_O|8 zhh_)7a;iK@?m`1E`B-xu5qt{7N6SGqT0N3*g$7>OD?9$t$y7w~X$}E2csz0T;@diX zg3j4PeHC$I6eZaGXWs=pto@E!Ed8P7Bq`-0#}sPUIGgMG z-9{}cuKW(BC(yly(ifO-&v8y<`NNlUzuE!>GfO&xo|GhKuGz}<3$L5cdsega!mgRr zJ*?L3A*l));Bhw$aqT}6`@jT9cYS59MvSyOhFc)u= z)??A9$wtYxa;DqYv@FrFaCJ*@R>hBbi8k|JZig#OR_2y7<@6kN7rJt_h-X>XE8ozm zj;exl72muW+S&ZxnkF$5r}bR3CmWEN%_C;$Ul!dfsdkpqD-}z<(`CfZ9z5; z4$K($n-xrG23pT~S)E7Z7Bz3qPLX4L6p9S+QI0hc`J;E`If~uT?eE6C?%)?*93?R2 zR13{oXHRkzA4j*#Z}jL;%V*fzn@>;v78Z2b(NuaS&A!0aIdP1iKczCAuwq&fpG73z zw?0*~hPYn%v$ieF!`h^{k&6`K&gTPavoc)b$7&wx7Iyk!ux!^<95iq}wE6b6m5!QM z`hu`uw-;Wbqu1L5c(Gr0Yx^|I6>M3Vk2hpn>VS`G2vPXNA}CdI&=CJKcWSY_DR#mq z*e#Ltb~c4W9T@+vO7}|Kk)Q;tpK0WLMP+6S{=g-&6Z{dXwUw3O(I27&&6XyTWJF5x z;w`3N=Cx8STxh1V?4#>h_uZ@;bnl=Iam73^8(g^9m(HV&@s^2?0((?Okt=EMna+OV zxElm7qmtApp<$g1&+hjGAOEGPiVpa*xw-;eHJkl!$G1zb>jJS`GwL$_CKr%)Eia0*)!Bmi=?$%HLuF_ zsTe2#i9(01Z(ZBwV!`E@OoSHs;%nen8|V6@Q?oJuqp=cpBRjN%54hOJyHbjdwqI%X zmN5v%KAlDa%Ut^#;`U+3B|bPLFBIicG$o-PFVd8rg50&A$d+b_w3s9MHNt_K#RPz7R*63 ztyquQUebX}X}){-}lr zq(;m{_Wy3fmI%3wpbE~lCrwXJJ%7oe1s0F>3p>w>HJoasP7j_o1gT^!H6{(OS1#cK z?-!QYx#$PIDi*l5f9zUW^Tp;eRiry`2}c3cg|(y0RoWNz0>ziPQUZgs6`S0lh;Y~ReI@I{9+ zzBxZAj}^ei+rGjS>QkA)_c%D@FJb=-*?Dixb^UY77MwtGRsF4;E2c!3%o{9eYz-D~ z=aU+c4ZfT8Fq;rGRppdl{pmT)*<1if10RMe)x!dN!v+{nkT76yv&+MZ{`6AKi+Nt4 z(56zQ{Vu1u=vxFi29ir}30|!T&~HTUT+=0ce?oxHt^hLK^o<{77YrZI|gAQU5$9}P+%ih#U z<7n#p#-oryzQpt45B$=&h7J=zP_1dfh>CdWJt|Ay+wy|sGAe(TiHdnCm7%6AJI?|X z9=R4>{~fh^2uzOSsF8E=ng|uy---aQt804V(NHgnYlt4FDN>7CiAo9p==$u zB^dS)Fpm@Hwy7J}L%5#1evuUtr5ZN^btAxoTFchu>b9$yR;;_*sq^~m@+2nv#CDO+|cJOL$K^04EE#k#TI}T(1ke7X2OHVU{j6;&bz!I4^2dEE3C0t>d12g7rUnEu|3A{&f*CHG)&vj zQe)?~(x1ho`n%)ac;u2jASk<(@whZS>*FKj?ZXIoSCmgh6W_YXB~WJmbyW(~Cznov zn${O(Ni1T@FgR#?>0QlN^$kANTL@<-7?k-Pu68(OZ}8}4YJmn9jJ8uelj)ys&LX~7NGce!)c_)=9d5gG^tk*wJaG84gE`E~d zs5uO@mBbi{>PW2w1-<5>?70j}?_?Hm%}n%!VKWbr>#`w2tIUlm4+8J!DSRbK;mam0ML+CJ#%SR>7%3LlbibmTz%XKvR498`S_1KtN?UwHi*8smQi{agEg4D$bG|0u>k@@8=b z$U#-SrF2gGf34Xn3IO@PQ-?al2E6~_cu+Ovt^X>f^44&w_CcZkX^s~Ts_o?ls+y-Y zS;17ZzQMYeQ~9MH5`WHt7z1^g!k_*n%`2ggAQaS~PW;`5H{%D4LpQc~Vvd6J>(Ug3 z#h#s_j>idNq3NU@cnV}k8=oE}IoM((1RuLmMd2W}`uo`O7bI7jDs_C`zhG1gd@yAZ=QbMW+2PnEGO}gU=RGc`06Ju zt_+yX8-`HrfU>r|`%+6H0PbCOuvJo|%dBis%HM%bOOpcAjzozQ_Fi>ca3H{p(zthM z$FoKrZLLXfMHY~!ODqoTE&TGk>pAv;pML}Zn;?9v2WqV_ge~jal_?0_M7ULhe@O1i zF3PUK+)xM=2)Um~k=*|fmpHEj+=`z~J02h;%BjNdnVS!qTH27u0w+wk7waD zxj}#9fTl)nzPq52nFQZXQt|RHgaZW&VsVvIc#QDiZACN=l{on=3l!Z`^s4%3zVDTL zy1}IMD)UmRZG{0fS&50(-xhbS$ z(R5&gh?q(KTQ(Xi#M)w2_EMuu14Ql#LQDE~(q6zTk-L^b51@~hMOgTsv@eh?%ISTv z&<_KdrMja1(?)OD+?PCx42y+CK<2G3r`dVWtr=6I?~`6CL;@S|)W{*TX^6N<{(1c; z%qB>BPC$!y+6rQrI6y$*x|~|+5!O$LNIsxojX<7hoF#U8qgGWwy^xCQIn#c*QM3E+ zxLCoBa>JD3ExDcUSf6Z9QIP(jWoj?dNf3J+m?y{i45eW=Lc^!vu=*}5($K~ZX-;7Q!5Gq41|5;O&2N{>8^{3`#ZspB^xWdhB`Xj81s2ca zXY=f{<~zRnxUOgH9|I$@jDf)t;m;=#2ZaAH3xOi5 zoP!M>0vndt+-}djq44;!yRjxM0ms3`Dm@y?M>1KsYi*<_fFDfJW=bWkBRYF%r5f6J zjZPxaPGblSsO?xi{neOsI5+ezCs_UPHK|GtO(mJIb3>cjQeLexWJ8vv;nUun+~K)n zi26^t_ShMjTD@R>hsf+AOEReU7d^StgRfrdE{mxgs4x3_JD~G8=Hdlg4+r_#(S2ZW)Fv_9|h)# zaSsL5`D{{gMx`i|<#rUt> zn@S-ZIiY(xz(xYy_-46EdzVP0v7x5f~NqOOYPQ_6Y zzjSx_@yi?pQcgEE@wHDLm12%xV?0-0b9(Ok#yk;5*dAg_E0)CVmur6A>MiIwQ=_GC zwQVlRT7SRow;(rc1QK^{w|NcECkIP6^I}W0q;-~UJ?{QwP#c_g1U}XF<1=ZmCPn*~ zh1f0*JF$^<5=^>Y>>;;a>YL?do>+Vtw`tf)i(ffWbp=eO`vl%~rcwqMaHcWz*irKP}Q`pPU!Ty6vF*TCJWav zqcC|&ARN4MZd$(5f$L3cNf7KPZ!W32Y;9IEQefA{SAW|rePG;g0c$t)Y4=ZcR*~N= zoGwh!a>)LDU$s~X0&XM1p4AoeB|8tb`y+00TXJ> z_}AtKL(l}L<&8bwDEGK@wa`wIgOD?R@q@`ilyRL*iB2z}JWBDCUY3J+x7{%LfzOc_ z1kHpX&Pz;s#jE-DU?#FJROwwDKE%DUaBZoB`Dv6{lAgS6Y8bKILF5O<7z)bv~Dx+01Mx#q??6bD|fB za<*eN^Xl$EPhT(alS6 zi!S+x;ab9RGrO(!>A_mh>ES{id1eZP_-HI8N>D8I$(|h-RF7ZXc{rvTT zfI>XCZ3g3;>iB@i@Pxmy6Vp!7Ru110XP85@Y}q?MnLh3>l|?`2yV*5*#Lwm%L*F-k z!7!zlD^xVow50X3Wtk$(B_QR8WEewZp`2{IPd-{@BQsUoEBdfG@&1Jha=%hd0Cxzd z#zw`!k}qBT{&)I z1`BJ;@^JC#QL!O01}_ZEo7fqD^^&`T0rmX%O7aqa=8$^ljRau=%_7ImYqy%_iPv{7 vsJN%Jx3TQck4=Uy-1Pm|WDL?09JjSPrbNQM@~0drDELIAGL9O(c600d`2O+f$vv5yPK~#7F?VRCp z+b|4;Uwi%Ej=&K*0y{yvLDUJ7P7rO-bb@$;b^~>TbOWaoBpbNGDZX=ElcFS01Vnsy zxZF81DdEu|2vGtI!!Qg(AHKJ)w?l7_y?utU8Xqw@{?wj(b1<^wdfVfF0m$y5jJME* z!w_~{;Q#NT3~d7Zz{bLiCIP--i2X(Z?r=HgpW6crc5aa7d=nr8m(tBm2_Sh6a2PV4 zO(RtRhr#(DuyG^`Ku+IWx*0;E02IfoNjIelKsuO{0UGrOR49Oi5adwt z$So~^LnY~Eh>`-3(_^LODkv!cg&l{=DJ1}vmMz^x838EWyoFJOYw)C-mWIxN$_qOf z|FD(-4nu*>IAaX~94y@ovW5WFl$Pg^TL9b1FhWiNEZr=~l${KghB8ATN8KLHQg934 zP{B?HPB;Z{sJyg12QC4s+R4BPmjG4mWZ;BD07^Hlv^~I1CTPX>#Ne>mUe+@I!K2wk+xO)JfQOG8Be#eJvxF(;grIDlKbq7^m$a zV0C{7T~6b4zC&TIq04DpCgRlxQy-Gsl6e%yq0V%ZOvIhMT2{K})?!6s>wp^T`rjd_ zwnYNeRBm{LKqw6Dq05nTY>z@2&zr>K9C+yZ{DfTdv|l>48K?p>sRRciun z0a`4hiM0K2a-2sz45?WYa0|}6=kLq)Fk>fikfkOal z!(lnMNDDtl{K$E!2t!K+;Bx-hxm*Rl6AMEv6tGkPuAK}X)56b@`mRbv;q&OhgD{%Zszt`zajn;<5t_j8j zXpxT{c*90F(w>JvVgj_tORVpom`;PZ03z1}At&U1!FL0`@N*<342heJFJ&PlO70Ww z5O&m({>Q>l1A(*{E}BLNU4TP=LPaz>j|TH`2#Y}1=V2IzVHjup1%y?zrT=qPM*si- M07*qoM6N<$f{wlORR910 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b057ae7736fe4dd9cb924db82153076a9cd941a3 GIT binary patch literal 913 zcmV;C18)3@P)@~0drDELIAGL9O(c600d`2O+f$vv5yP1qDb@KtV&1f+7fIt-V*dNp@1~cxLQl zf6^$?ri#DKtmT~93nWRBBuO%aTj1r%v>tE2|23^IFvuNn@@!fg%)g(k``hb(V2s<~ zL+<=X3)eR=2%7-ea!v|Ay9g$8e5U0aE-E5a3aO-1QX27rY5Lg41@hgeL*=uzdli?Pdut0^GNo zCASt1XB+$#8`YDrg~qKFOeowt$GE<)5fPQ$Uy`!yc!odW7$d*&x@hnj*KxQB^={yAQyNBdq`)*qQ(EzJ&%z6tO zsI{9V@)=M@#?5N&W{DI~<}(!A%@QeK8EH3vqXQ}6I#PV~cC$nZxQyKO-HI=yfD7bd z`w5*%0W-UH^B+2s0_JwJX|I11DPV3lhZJ9kEHGyy6}22COfXg*qALnHysN6Tx7B0x3Cph*M)Cfdys>;jr388l%QP#v7@ z0jmIc*uIAY7zGR)Ew6!1K$j$gCTs$_BpEbe63{Klpb3kBu1N+>7zA`rGHBvmz>p+^ zCY}YjYBx)G6)-f(pov!jLz4`ecoZ-^$)Jf<0cVBlrbo+buzIX$>Fk-=&xNnSs(}5o nXL2^{I|z%PBuSDa$w1Bl3ixspCLUqa00000NkvXXu0mjfY?66P literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d42220267678de7617335c12f60fe2c215238886 GIT binary patch literal 745 zcmV@~0drDELIAGL9O(c600d`2O+f$vv5yPA}NQzm$XAR>Vbb~AtsqzsgTJWP?nlEJxn$1HcEkaTz2-RYj0+xh>h zIsm<=kFM671ONbFtc$bR&o?2f>p0sd(+gRQ-}&1Evk}~V(c$nP29BPn-t(6Xx_#{ z0fy$Tn-pN|Ug_aez(V6Ke`_?Dsgqs!6fo0Er)3IoG_IRlhrt9vHX9EGED+=uJ_Sr* zwQf?t0@v~lp8`(kP zL!HWn-+Te3!*9BP-0LO<{3rD#0|g`qD!1`8UqH!qGsl>G_UyI%{D|{j>va+5L!4J} z?o=7emkj6dlTejT=M~_p3dng{u4TTf5?(JEYMy&lB52(k&!HLiUw3gqgkO~RONJWf zQk7_F-F%p@sz@z2o?O$bGA$M0vTmlHBHqOLr1kq5=d&E77H}8mopv{$3$4?yI3MHu zRzqs}wKJ%qbu*_L!XN*>RkLp9Rzm?-YFO#;do_;m3o8}=t?*Zg@CWVwj}4UxKM4Qi z41ZiVgYZbs0-9bokI5B&Rj1{eGZx^prwfgF}}M_)$E)e-c@NTsKb_$B+ufw{rrsMGQq+`?tAvX>9di{r$k1 zTP!f(3VZE`=dYVHM7zU6Hh6ST3Az}h6?H@=tu3$IeDmhblkcQ_ubD9YT-l_PJ9nOZ za*tD?MZk$ev1Nv-o`Szv?9SJxM8m~mZkDIso+_N;*2lEr^ZW%be#H63R`#87+0pT5 zNAJON>Yp{-T*IDd9O#{u9X?Ugk}Kf2jZyL~mJ@uO2|X+ZZcGx&j6D+>j(IR7t27u2 z9gsj0J{j1se}+<#$b%KnZrK?ryEASxs1f;)fBQs_nqeZFW!}zBJ)0FQ)_$0-9iJfF zoElScc&ey~?D=mwl3P=E<{bCBxo(wh`?@#1S(h~!^A=THXq|Px#!d0a8pp#i?um2k zZv1>R-}XNLE0zbn<`;Ovz6eP!Yu(ZkcAll-Xxskf*>@z%63>0+-}BMgq+NN*rWW}b zc^Om61Kx}NiY$6+-F0PV@tLb3m(&h7OxxI%WLnhqvgz}EWs!Y#F{_TAbNtUbpCNl) zjFa`ULX!`batmtbFZgqx(fo<`;}=T%+k{2jozE3JPi5NKb5hbOzUAEF#HHsi{Pogw z%hOm}$NTu_uZ^8kPaL`Ep#3Uot(}f}$|B^5 zeYE$|Pn9w)`PYxRy4Nj!8(q?I=faAx``w{>s_N->Id7wrxNq6`i^RV_Y&3IfSUG>I zfLOKH=hVNs*S^j*J2WfHq(D?S+j{2LyX_ONexG!z%*35Jz9dXgF6vLR%7vdQTPqmU zUrboM>DDp7wks?vlK2-s*gN}Of}7&ejtCF)65h^F_K@h zLHr0`W%~}tnrkOd&fC4faGU*}2rh@!|2cMdPE>Aq*U`x=a_9-~O)rLZCAZqtHDjhr zXYNj|_HlF0VZ2d!=lA@SrOwwMcE@a;>%8h>z|N%^)=Lf56Qgc&`|LZzGgJ2LwvD}t z_g&9D^ga{iUcMpOZs#YtyFOd?uwVZ3Vzd7A@Bd$($}ZWw>ET~VwbXk#*9{*vUYT=2 zFGDCkp8fsy&J6}}XMZ*xoSh)NC7-3hXx0_}OR^=YU&A)ME%A=+VLPXP|JVPB>9TuU zzKQO2YMak}HJ~=;4@4V)!znqS^4$<)4S^ z*TuLS`<>#NbUT;FVfICb@*0CTPIFs}Q$I?lb8fK95Z&@C`3#@0J9Bu3yup$0;U+&l z@;9z&(OW6hEVe&>T1NJ>eP&CGiu7)83ftDIc>a~LZC+u`(GJ%RzjLN;>d&PXPcH7g zcyi}44c(*2nF>c<1F3xRBqq6}Xr1BXXMdMCytosf2+8gor|KvCJ!AK-!KwjR!Z3Kc L`njxgN@xNAoRKH3 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..0cf66e6a8f68f0f99e123292658038ac911b13be GIT binary patch literal 914 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7OGooCO|{#S9EO-XP4l)OOlRpde#$ zkh>GZx^prwfgF}}M_)$E)e-c?47?{;PT^vIy7~kH>F1+m^!usH{5=RTSq5zK~ zPYxR=bGPH!1G-IHI80n87z9Y};1PLyLrUFmP3`Nf>(6KJtbf$GWp{e2t!_gH2&~{S z3bDSDxT0>w^UL=$*qS~c*uS1x;*idnocHTPxKFK{zk-?TGw;)vy7R@aE`P5O%Htr( z9B_Y<)$!4H)Qk=56RL82G_qN$w&?sRa&5Z3@lfTl-B*NdI*%X9 z&&}R&_E$DPH;2t1mk0*aeBSRC?|wTSPi5yf)LvDX{o$w8yje5O{c;KYbxpxS?&1|wP6m-U9Z$om|f8@TV!rb z;=agu7l!1u!duQhQ{0eiF4!=0YWc+6*-9UFcTBTwSbA5`xLLk>hSgD>=zxZ}jk`{4 z^Ytw3)qW{f@bHS+pMJKtMkX=TPdu+L;*_m^ z^HIf; zG-F$6wf830{8QH6J1>cB{x?bC|K*FkS{w?;51jFwP{#gN=cre4-1Yg<%|AX?G`7b+ z>3SC^?|A3Pox{xvZ!#7*{*`KBRrv7b$;JpVZZ(*+FT?lB0h)tB3GZx^prwfgF}}M_)$E)e-c@NatAzJ978G?-`?Kq%j_uN@Q`sfgQACKV+-4g zhFO^n6FLs4Dx`NbFfe^HU^KdA$iBU<`qS?vy?!3q_H{jO!it`gN;>)$t-V~d?zE-; z^CfdiEms%CE%vY8pMRR+aQDv6v)n)5?Va=cg+5c!{l^*4wd(fGIA~;cKXtF;yW2(w zQa`;>`qg}AduqcOqi0KR)~;mumBcWEF@dc?k|7O)u`OF+v#sJK3v2bP$c9@j9*>s@ zZ+#OUkn6Oh@KR@#n8Iv<%sH=Ibh$r>&GIT;G>x@_(>FD?dID<&Pd(Q%gFWr5Zyk31 z``Ye*mHp9YmoG$Sta;d5ulHQ>-w(qt`}4|e_IK}7`2N*C5vTo_F2X6YQ~l1g>O-;x z{u0mT6|mnCZriEOQ|vGCWS+qh+lLeH2-~?}_afLpUFI{n(;7gblCx-A-#>-#xsnUa y0=?B;6rSws&PcCb^lde#U`F2esVbhcHu5{L_`7U3nNkjnTLw>8KbLh*2~7Y@64L(w literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..fe6485092b50f6d54718ae205480c18fd83d0319 GIT binary patch literal 521 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7OGooCO|{#S9EO-XP4l)OOlRpde#$ zkh>GZx^prwfgF}}M_)$E)e-c@Na`QZ0978G?-`>6J$?PcM_V9Yh0$HE`<_Ed9 zv08V^uF>YmK5*CY_v4upKOZPx+!0^YBB0jXsp2`QFMOHgvYo!he#L)(MXmPwb*nt| zH9>+hR?y1bp$68CTanF*}f?L5gPQ@&@$ z)%`lQ^pmsIvCo&xuQBa$b+0_jrn-+q-f*|wwV+4cALpspc|1M&Jy_53@JR$KdJe=d#Wzp$PznB+dN* literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f57e973cb1ebcf55784672765e556342a8af2648 GIT binary patch literal 692 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7OGooCO|{#S9EO-XP4l)OOlRpde#$ zkh>GZx^prwfgF}}M_)$E)e-c?47??agT^vIy7~kHx>wDNhg!RGU^UVFJ%$r&J zWgB^K@iuU3nP~`b@j0NKFDNXc<}qWzU51>fg+X84d$Z)@pVwRx6`FeK#XOTf8TEz^ zAn^EDwa;x|+P-J|_8G6*;QxJ}440`xo$vjyt?R8t)J*KZAIrbS&|0ve@JaqP8BRZU z@s9jAuf@K_{IvS~wXNfvK)l7IstuVBUrPzjRn}wQeU|^wK5G{dYX(aP#tE7XMyw9Y z7*6O3hVST^eP|o2i;K$=8%?#xGCA1-6Vsf(8{11unQVB?JaH+<1KF}#rA;D?F7{nY zvlcQu{FDDO?n9NO$fBmT$J)J$zAT@`yVZiPCs3e!xnlbvpTy-qg+*5ITQgdSWOeJc3t`2A{~GJMEH&EY_b8$`xozbYyUDap5dsiaKQ=fPnrK%WS>kp z-Y}=;LG1CE8CMnh8Fv~pzt=kx@5sAJW}2GOdvOzm%eMj`E?>hYFs<9>=dQ@B%1gF# z9gpA3vn?n6Mv}GYlNSuns<)NCQPs6&=w80S_4N5oOB`qTAp9)CFq26km?1@s`9?E*Vhl0EFW0sy;#3=%L4v;wu$UNk5=R#Vfm!4qq}nAx}Ohs%RG^9H}MY9TJf`* rccYE!+)uJXiO~x`)`B9w;{<=~hGt>scf~HiM8@Fh>gTe~DWM4fsCX7K literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..fe98320a74999c3d7eee93fc7226093cb237b1cb GIT binary patch literal 869 zcmV-r1DgDaP)@~0drDELIAGL9O(c600d`2O+f$vv5yPrz2AMn-49JPdjAVRDb zz8aUaS`-W+;Jll^8^R2UULl=)7B(Ph{+n38p(w!DS*lSVkl-cKdzaFa}T<11Rh@%KH=R2K`mUd-OLn0d!Q3JI6H+ zH~JDj=b6+@eWCLK#|Y@>->o*=Z2(IQ7vO7gN6nNM88FU#;_(>3POUNC4AUE9HURC* z&>M^=S4SNxr5(T>=n3rr-XTr$lAm?j0W=s-Zp;`!CIRC^;Og6dOu15MG5m~fCLYWD zRZyy_FI4Vu!!yTd;BSYWnj9~-jrAG-2GPV?WjcoW0IV!YcK3g0@cj>WbU(Q!oEO)G zy5EcBn(uqsBEjEr`gbN7(cyWo`(@2A2nZ4Ve^Ic5g^T% zf40}Cl;si7#rlYfn=h;e(4jx`J57zWajnQ``#cy>W!FeaGJUT)uI2c%?sEk&2Mmg z`uhrsr1@LclL}C_fbWLotR)JN;n>1&({fb{{)WJLHvd6@-v_Iwlzar6dZszx@ke|- vHF9X(;_*j(PPGS#h=_=Yh=_=Yh)(4%xC;=rArYb?00000NkvXXu0mjfU676V literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..78c892fe4c0e4504106906d126eb9b0cb33fc702 GIT binary patch literal 884 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7OGooCO|{#S9EO-XP4l)OOlRpde#$ zkh>GZx^prwfgF}}M_)$E)e-c?47??RdT^vIy7~jr)oqO9rq}9Kh?}6YA-aU?{ zP39ZS3d|;$d6;#W7g#r5bNV=uDDAvj@i|G(VNBX`vkX?PycTnyDqnz=O)j`rUK9XzWW4E<=K3Z zXUYBSx_6V|Bf*El^}H{Y%;j$RH*IUIQ8)MSX|6b#-@1wS&TOmSJEtzFmst6=!{tiH z^gow7K3Tix-VTv++&y!}PDiE~VS{J4Gg~&*&f2?nL9As)RPa)3Ee+F0D))~?X;eR7 zc$e!I+q-z3myL2$Hoi^nVcGd@+cA@Db@yE9{R=(XKb))=I~8;A{laXPzsCZ9Z{sm_ zQm^-E`X={l>VnwnjQed;3waHkDtp_@v@@hzq+M1C8vVR89}JlJaILDe=n(``KSeFLdK%i{L4dzmzp4>A+O6fR&r2 zmP#hvWWM-%jaw(1ghJI^_03Dd1#R!SU%a_tOSz-1QM3MIq0K7HZx*a`O4B*npqu8B zxmTgtB0X}0xCzs4!NYm>p?5uGXA8Y!VBRllrd8gcEhYY$sl4n$W>H#hLoe6m4Z_Cm zM}M8Y*PwhX@%Hx_&&71FF-V_xiq(2zC~FzboKs@){c(P^k?5yc6UvPi9C`ZE_MF?3 z8*Asx@qfq9!Tp_m3LCEn4-)EG&1ieV{og^xaOOE&vn~HWY*@`$?`Wqq*S}o+`os@k zH|&l)YgBoG|D*7!A2PD`+ixfd`fHeHE?nfA6g6e)h6#$YyH~pP)~LCf96D#N{_`wD vr_}R%4d*qV>~t&NxlQU#4I;q`1j*&A$L*B&SnS{g%p?q+u6{1-oD!M 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 } +}