Initial commit of local MijickCamera package
1
.github/CODEOWNERS
vendored
Normal file
@ -0,0 +1 @@
|
||||
* @FulcrumOne
|
||||
128
.github/CODE_OF_CONDUCT.md
vendored
Normal file
@ -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.
|
||||
1
.github/CONTRIBUTING.md
vendored
Normal file
@ -0,0 +1 @@
|
||||
Coming soon...
|
||||
14
.github/FUNDING.yml
vendored
Normal file
@ -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']
|
||||
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@ -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
|
||||
18
.github/ISSUE_TEMPLATE/🚀-feature-request.md
vendored
Normal file
@ -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)_
|
||||
42
.github/ISSUE_TEMPLATE/🦟-bug-report.md
vendored
Normal file
@ -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 |
|
||||
14
.github/workflows/publish-to-cocoapods.yml
vendored
Normal file
@ -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
|
||||
14
.github/workflows/run-tests.yml
vendored
Normal file
@ -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'
|
||||
11
.gitignore
vendored
Normal file
@ -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
|
||||
201
LICENSE
Normal file
@ -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.
|
||||
21
MijickCamera.podspec
Normal file
@ -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
|
||||
22
Package.swift
Normal file
@ -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]
|
||||
)
|
||||
226
README.md
Normal file
@ -0,0 +1,226 @@
|
||||
<!--Hero Image-->
|
||||
<p align="center">
|
||||
<picture>
|
||||
<img alt="MijickCamera Hero" src="https://github.com/Mijick/Assets/blob/main/Camera/hero.png" width="100%">
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
<!--Text Header-->
|
||||
<p>
|
||||
<h3 align="center">Camera made simple</h3>
|
||||
<p align="center">Significantly reduces implementation time and effort. Keeps your code clean.</p>
|
||||
</p>
|
||||
|
||||
<!--Links: Demo, Wiki, Roadmap-->
|
||||
<p align="center">
|
||||
<a href="https://link.mijick.com/camera-demo" rel="nofollow"><b>Try demo we prepared</b></a>
|
||||
|
|
||||
<a href="https://link.mijick.com/camera-wiki" rel="nofollow"><b>Framework documentation</b></a>
|
||||
|
|
||||
<a href="https://link.mijick.com/camera-roadmap" rel="nofollow"><b>Roadmap</b></a>
|
||||
</p>
|
||||
|
||||
<br>
|
||||
|
||||
<!--Labels-->
|
||||
<p align="center">
|
||||
<img alt="Labels" src="https://github.com/Mijick/Assets/blob/main/Camera/labels.svg"/>
|
||||
</p>
|
||||
|
||||
<br>
|
||||
|
||||
<!--GIFs-->
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Camera Position</th>
|
||||
<th>Media Capturing</th>
|
||||
<th>Gestures</th>
|
||||
<th>Filters</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<img src="https://github.com/Mijick/Assets/blob/main/Camera/Gifs/Camera-1.gif"/>
|
||||
</td>
|
||||
<td>
|
||||
<img src="https://github.com/Mijick/Assets/blob/main/Camera/Gifs/Camera-2.gif"/>
|
||||
</td>
|
||||
<td>
|
||||
<img src="https://github.com/Mijick/Assets/blob/main/Camera/Gifs/Camera-3.gif"/>
|
||||
</td>
|
||||
<td>
|
||||
<img src="https://github.com/Mijick/Assets/blob/main/Camera/Gifs/Camera-4.gif"/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<br><br>
|
||||
|
||||
<!--Buttons-->
|
||||
<p>
|
||||
<!--Website-->
|
||||
<a href="https://link.mijick.com/app">
|
||||
<img alt="Visit our Website" src="https://github.com/Mijick/Assets/blob/main/Common/Buttons/website.png" height="40px">
|
||||
</a>
|
||||
<!--Discord-->
|
||||
<a href="https://link.mijick.com/discord">
|
||||
<img alt="Join us on Discord" src="https://github.com/Mijick/Assets/blob/main/Common/Buttons/discord.png" height="40px">
|
||||
</a>
|
||||
<!--Linkedin-->
|
||||
<a href="https://link.mijick.com/linkedin">
|
||||
<img alt="Follow us on LinkedIn" src="https://github.com/Mijick/Assets/blob/main/Common/Buttons/linkedin.png" height="40px">
|
||||
</a>
|
||||
<!--GitHub-->
|
||||
<a href="https://link.mijick.com/github">
|
||||
<img alt="See our other frameworks" src="https://github.com/Mijick/Assets/blob/main/Common/Buttons/github.png" height="40px">
|
||||
</a>
|
||||
<!--Medium-->
|
||||
<a href="https://link.mijick.com/medium">
|
||||
<img alt="Read us on Medium" src="https://github.com/Mijick/Assets/blob/main/Common/Buttons/medium.png" height="40px">
|
||||
</a>
|
||||
<!--Buymeacoffee-->
|
||||
<a href="https://link.mijick.com/buymeacoffee">
|
||||
<img alt="Buy us a coffee" src="https://github.com/Mijick/Assets/blob/main/Common/Buttons/buymeacoffee.png" height="40px">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<!--Features-->
|
||||
# ✨ Features
|
||||
<table><tbody>
|
||||
<tr>
|
||||
<td>🙏🏻</td>
|
||||
<td>Automatically handles permissions</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>🖼️</td>
|
||||
<td>Image capture</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>🎬️</td>
|
||||
<td>Video capture (with or without sound)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>📸</td>
|
||||
<td>Camera position changes</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>🔍️</td>
|
||||
<td>Supports manual zoom</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>👁️</td>
|
||||
<td>Supports manual focus</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>🎞️</td>
|
||||
<td>Changeable frame rate</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>📺️</td>
|
||||
<td>Changeable camera resolution</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>🙈</td>
|
||||
<td>Camera filters</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>🔦</td>
|
||||
<td>Torch</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>📸</td>
|
||||
<td>Flash</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>⏱️</td>
|
||||
<td>Other camera settings (exposure duration, target bias, ISO, HDR mode and more)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>☢️</td>
|
||||
<td>Displays error screen if permissions are not granted</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>🖼️</td>
|
||||
<td>Displays captured media screen</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>📱</td>
|
||||
<td>Modern and minimalistic UI</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>🕺</td>
|
||||
<td>Beautiful animations</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>🚧</td>
|
||||
<td>Fully customizable screens</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>🤏🏼</td>
|
||||
<td>Gestures support</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>📲</td>
|
||||
<td>Blocks screen orientation change</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>⚡️</td>
|
||||
<td>Supports Swift 6</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>🚀</td>
|
||||
<td>... and others</td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
|
||||
<!--Description-->
|
||||
# ☀️ 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:
|
||||
|
||||
<p>
|
||||
<h3>The power of simplicity</h3>
|
||||
<p>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.</p>
|
||||
<img alt="Code Example 1" src="https://github.com/Mijick/Assets/blob/main/Camera/Code/power-of-simplicity.png" width="100%">
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<h3>Three in one</h3>
|
||||
<p>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!</p>
|
||||
<img alt="Code Example 4" src="https://github.com/Mijick/Assets/blob/main/Camera/Code/three-in-one.png" width="100%">
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<h3>Engineered for limitless creativity</h3>
|
||||
<p>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.</p>
|
||||
<img alt="Code Example 1" src="https://github.com/Mijick/Assets/blob/main/Camera/Code/limitless-creativity.png" width="100%">
|
||||
</p>
|
||||
|
||||
|
||||
### There is much more besides:
|
||||
- Advanced camera controls.
|
||||
- Gesture support.
|
||||
- Thoroughly designed animations.
|
||||
- Supports Swift 6.0.
|
||||
- ... and much more.
|
||||
|
||||
|
||||
<!--Documentation-->
|
||||
# 🚀 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-->
|
||||
# 🍀 Community
|
||||
Join the welcoming community of developers on [Discord](https://link.mijick.com/discord).
|
||||
|
||||
<!--Contribution-->
|
||||
# 🌼 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). <br>
|
||||
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). <br>
|
||||
If you would like to contribute, please refer to the [Contribution Guidelines](https://github.com/Mijick/Camera/blob/main/.github/CONTRIBUTING.md).
|
||||
|
||||
<!--Sponsorship-->
|
||||
# 💜 Sponsor our work
|
||||
Support our work by [becoming a backer](https://link.mijick.com/buymeacoffee).
|
||||
6
Sources/Internal/Assets/Colors.xcassets/Contents.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
6
Sources/Internal/Assets/Icons.xcassets/Contents.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
16
Sources/Internal/Assets/Icons.xcassets/mijick-icon-cancel.imageset/Contents.json
vendored
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
BIN
Sources/Internal/Assets/Icons.xcassets/mijick-icon-cancel.imageset/mijick-icon-cancel.png
vendored
Normal file
|
After Width: | Height: | Size: 659 B |
16
Sources/Internal/Assets/Icons.xcassets/mijick-icon-change-camera.imageset/Contents.json
vendored
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
16
Sources/Internal/Assets/Icons.xcassets/mijick-icon-check.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "mijick-icon-check.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
BIN
Sources/Internal/Assets/Icons.xcassets/mijick-icon-check.imageset/mijick-icon-check.png
vendored
Normal file
|
After Width: | Height: | Size: 634 B |
16
Sources/Internal/Assets/Icons.xcassets/mijick-icon-crosshair.imageset/Contents.json
vendored
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
BIN
Sources/Internal/Assets/Icons.xcassets/mijick-icon-crosshair.imageset/mijick-icon-crosshair.png
vendored
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
16
Sources/Internal/Assets/Icons.xcassets/mijick-icon-flash-auto.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "mijick-icon-flash-auto.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
BIN
Sources/Internal/Assets/Icons.xcassets/mijick-icon-flash-auto.imageset/mijick-icon-flash-auto.png
vendored
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
16
Sources/Internal/Assets/Icons.xcassets/mijick-icon-flash-off.imageset/Contents.json
vendored
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
BIN
Sources/Internal/Assets/Icons.xcassets/mijick-icon-flash-off.imageset/mijick-icon-flash-off.png
vendored
Normal file
|
After Width: | Height: | Size: 913 B |
16
Sources/Internal/Assets/Icons.xcassets/mijick-icon-flash-on.imageset/Contents.json
vendored
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
BIN
Sources/Internal/Assets/Icons.xcassets/mijick-icon-flash-on.imageset/mijick-icon-flash-on.png
vendored
Normal file
|
After Width: | Height: | Size: 745 B |
16
Sources/Internal/Assets/Icons.xcassets/mijick-icon-flip-off.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "mijick-icon-flip-off.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
BIN
Sources/Internal/Assets/Icons.xcassets/mijick-icon-flip-off.imageset/mijick-icon-flip-off.png
vendored
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
16
Sources/Internal/Assets/Icons.xcassets/mijick-icon-flip-on.imageset/Contents.json
vendored
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
BIN
Sources/Internal/Assets/Icons.xcassets/mijick-icon-flip-on.imageset/mijick-icon-flip-on.png
vendored
Normal file
|
After Width: | Height: | Size: 914 B |
16
Sources/Internal/Assets/Icons.xcassets/mijick-icon-grid-off.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "mijick-icon-grid-off.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
BIN
Sources/Internal/Assets/Icons.xcassets/mijick-icon-grid-off.imageset/mijick-icon-grid-off.png
vendored
Normal file
|
After Width: | Height: | Size: 555 B |
16
Sources/Internal/Assets/Icons.xcassets/mijick-icon-grid-on.imageset/Contents.json
vendored
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
BIN
Sources/Internal/Assets/Icons.xcassets/mijick-icon-grid-on.imageset/mijick-icon-grid-on.png
vendored
Normal file
|
After Width: | Height: | Size: 521 B |
16
Sources/Internal/Assets/Icons.xcassets/mijick-icon-light.imageset/Contents.json
vendored
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
BIN
Sources/Internal/Assets/Icons.xcassets/mijick-icon-light.imageset/mijick-icon-light.png
vendored
Normal file
|
After Width: | Height: | Size: 692 B |
16
Sources/Internal/Assets/Icons.xcassets/mijick-icon-photo.imageset/Contents.json
vendored
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
BIN
Sources/Internal/Assets/Icons.xcassets/mijick-icon-photo.imageset/mijick-icon-photo.png
vendored
Normal file
|
After Width: | Height: | Size: 869 B |
16
Sources/Internal/Assets/Icons.xcassets/mijick-icon-video.imageset/Contents.json
vendored
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
BIN
Sources/Internal/Assets/Icons.xcassets/mijick-icon-video.imageset/mijick-icon-video.png
vendored
Normal file
|
After Width: | Height: | Size: 884 B |
@ -0,0 +1,46 @@
|
||||
//
|
||||
// AVCaptureVideoOrientation++.swift of MijickCamera
|
||||
//
|
||||
// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
|
||||
// - Mail: tomasz.kurylik@mijick.com
|
||||
// - GitHub: https://github.com/FulcrumOne
|
||||
// - Medium: https://medium.com/@mijick
|
||||
//
|
||||
// Copyright ©2024 Mijick. All rights reserved.
|
||||
|
||||
|
||||
import SwiftUI
|
||||
import AVKit
|
||||
|
||||
// MARK: To Angle
|
||||
extension AVCaptureVideoOrientation {
|
||||
func getAngle() -> Angle { switch self {
|
||||
case .portrait: .degrees(0)
|
||||
case .landscapeLeft: .degrees(-90)
|
||||
case .landscapeRight: .degrees(90)
|
||||
case .portraitUpsideDown: .degrees(180)
|
||||
default: .degrees(0)
|
||||
}}
|
||||
}
|
||||
|
||||
// MARK: To UIImageOrientation
|
||||
extension AVCaptureVideoOrientation {
|
||||
func toImageOrientation() -> UIImage.Orientation { switch self {
|
||||
case .portrait: .downMirrored
|
||||
case .landscapeLeft: .leftMirrored
|
||||
case .landscapeRight: .rightMirrored
|
||||
case .portraitUpsideDown: .upMirrored
|
||||
default: .up
|
||||
}}
|
||||
}
|
||||
|
||||
// MARK: To UIDeviceOrientation
|
||||
extension AVCaptureVideoOrientation {
|
||||
func toDeviceOrientation() -> UIDeviceOrientation { switch self {
|
||||
case .portrait: .portrait
|
||||
case .portraitUpsideDown: .portraitUpsideDown
|
||||
case .landscapeLeft: .landscapeLeft
|
||||
case .landscapeRight: .landscapeRight
|
||||
default: .portrait
|
||||
}}
|
||||
}
|
||||
20
Sources/Internal/Extensions/AVVideoComposition++.swift
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
20
Sources/Internal/Extensions/Animation++.swift
Normal file
@ -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 }
|
||||
}
|
||||
14
Sources/Internal/Extensions/CIFilter++.swift
Normal file
@ -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 {}
|
||||
24
Sources/Internal/Extensions/CIImage++.swift
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
21
Sources/Internal/Extensions/CameraUtilities++.swift
Normal file
@ -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
|
||||
}}
|
||||
}
|
||||
22
Sources/Internal/Extensions/CaseIterable++.swift
Normal file
@ -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]
|
||||
}
|
||||
}
|
||||
35
Sources/Internal/Extensions/FileManager++.swift
Normal file
@ -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" }
|
||||
}
|
||||
19
Sources/Internal/Extensions/Task++.swift
Normal file
@ -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))
|
||||
}
|
||||
}
|
||||
26
Sources/Internal/Extensions/UIImage.Orientation++.swift
Normal file
@ -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
|
||||
}}
|
||||
}
|
||||
43
Sources/Internal/Extensions/UIView++.swift
Normal file
@ -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 }
|
||||
}
|
||||
17
Sources/Internal/Extensions/View++.swift
Normal file
@ -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) }
|
||||
}
|
||||
40
Sources/Internal/Manager/CameraManager+Attributes.swift
Normal file
@ -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
|
||||
}
|
||||
108
Sources/Internal/Manager/CameraManager+MotionManager.swift
Normal file
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}}
|
||||
}
|
||||
127
Sources/Internal/Manager/CameraManager+PhotoOutput.swift
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
158
Sources/Internal/Manager/CameraManager+VideoOutput.swift
Normal file
@ -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")
|
||||
}
|
||||
438
Sources/Internal/Manager/CameraManager.swift
Normal file
@ -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<CS: CaptureSession, CDI: CaptureDeviceInput>(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
|
||||
}}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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 }
|
||||
}
|
||||
@ -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?
|
||||
}
|
||||
@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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) }
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
16
Sources/Internal/Miscellaneous/Typealiases.swift
Normal file
@ -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
|
||||
19
Sources/Internal/Models/CameraExposure.swift
Normal file
@ -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
|
||||
}
|
||||
28
Sources/Internal/Models/MCameraMedia.swift
Normal file
@ -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 }
|
||||
}
|
||||
68
Sources/Internal/UI/Camera View/CameraView+Bridge.swift
Normal file
@ -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 {}
|
||||
}}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
81
Sources/Internal/UI/Camera View/CameraView+Grid.swift
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
279
Sources/Internal/UI/Camera View/CameraView+Metal.swift
Normal file
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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 }
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}}
|
||||
}
|
||||
@ -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
|
||||
}}
|
||||
}
|
||||
@ -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
|
||||
}}
|
||||
@ -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 }
|
||||
}
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||