Initial commit
Add CheapTeleprompter iOS app with teleprompter functionality, camera integration, voice trigger support, and subscription management. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
63
.gitignore
vendored
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# Xcode
|
||||||
|
build/
|
||||||
|
DerivedData/
|
||||||
|
*.xcodeproj/xcuserdata/
|
||||||
|
*.xcworkspace/xcuserdata/
|
||||||
|
*.xcodeproj/project.xcworkspace/xcuserdata/
|
||||||
|
*.xccheckout
|
||||||
|
*.moved-aside
|
||||||
|
*.xcuserstate
|
||||||
|
*.xcscmblueprint
|
||||||
|
|
||||||
|
# CocoaPods
|
||||||
|
Pods/
|
||||||
|
Podfile.lock
|
||||||
|
|
||||||
|
# Swift Package Manager
|
||||||
|
.build/
|
||||||
|
.swiftpm/
|
||||||
|
Package.resolved
|
||||||
|
|
||||||
|
# Carthage
|
||||||
|
Carthage/Build/
|
||||||
|
Carthage/Checkouts/
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
.AppleDouble
|
||||||
|
.LSOverride
|
||||||
|
._*
|
||||||
|
|
||||||
|
# Node.js
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist/
|
||||||
|
*.ipa
|
||||||
|
*.dSYM.zip
|
||||||
|
*.dSYM
|
||||||
|
|
||||||
|
# Archives
|
||||||
|
*.xcarchive
|
||||||
|
|
||||||
|
# Playgrounds
|
||||||
|
timeline.xctimeline
|
||||||
|
playground.xcworkspace
|
||||||
|
|
||||||
|
# fastlane
|
||||||
|
fastlane/report.xml
|
||||||
|
fastlane/Preview.html
|
||||||
|
fastlane/screenshots/**/*.png
|
||||||
|
fastlane/test_output
|
||||||
|
|
||||||
|
# Code Injection
|
||||||
|
.inject*
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
*.hmap
|
||||||
|
*.log
|
||||||
|
*.orig
|
||||||
|
*~
|
||||||
362
CheapTeleprompter.xcodeproj/project.pbxproj
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
// !$*UTF8*$!
|
||||||
|
{
|
||||||
|
archiveVersion = 1;
|
||||||
|
classes = {
|
||||||
|
};
|
||||||
|
objectVersion = 77;
|
||||||
|
objects = {
|
||||||
|
|
||||||
|
/* Begin PBXBuildFile section */
|
||||||
|
7FEDCFFB2F1972C10022FFE5 /* Subscriptions.storekit in Resources */ = {isa = PBXBuildFile; fileRef = 7FEDCFFA2F1972C10022FFE5 /* Subscriptions.storekit */; };
|
||||||
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
|
/* Begin PBXFileReference section */
|
||||||
|
7F3DA60B2F117B11003E4214 /* CheapTeleprompter.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CheapTeleprompter.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
7FEDCFFA2F1972C10022FFE5 /* Subscriptions.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = Subscriptions.storekit; sourceTree = "<group>"; };
|
||||||
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
7F3DA60D2F117B11003E4214 /* CheapTeleprompter */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
path = CheapTeleprompter;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
7F3DA6082F117B11003E4214 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXGroup section */
|
||||||
|
7F3DA6022F117B11003E4214 = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
7FEDCFFA2F1972C10022FFE5 /* Subscriptions.storekit */,
|
||||||
|
7F3DA60D2F117B11003E4214 /* CheapTeleprompter */,
|
||||||
|
7F3DA60C2F117B11003E4214 /* Products */,
|
||||||
|
);
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
7F3DA60C2F117B11003E4214 /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
7F3DA60B2F117B11003E4214 /* CheapTeleprompter.app */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXGroup section */
|
||||||
|
|
||||||
|
/* Begin PBXNativeTarget section */
|
||||||
|
7F3DA60A2F117B11003E4214 /* CheapTeleprompter */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = 7F3DA6162F117B11003E4214 /* Build configuration list for PBXNativeTarget "CheapTeleprompter" */;
|
||||||
|
buildPhases = (
|
||||||
|
7F3DA6072F117B11003E4214 /* Sources */,
|
||||||
|
7F3DA6082F117B11003E4214 /* Frameworks */,
|
||||||
|
7F3DA6092F117B11003E4214 /* Resources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
fileSystemSynchronizedGroups = (
|
||||||
|
7F3DA60D2F117B11003E4214 /* CheapTeleprompter */,
|
||||||
|
);
|
||||||
|
name = CheapTeleprompter;
|
||||||
|
packageProductDependencies = (
|
||||||
|
);
|
||||||
|
productName = CheapTeleprompter;
|
||||||
|
productReference = 7F3DA60B2F117B11003E4214 /* CheapTeleprompter.app */;
|
||||||
|
productType = "com.apple.product-type.application";
|
||||||
|
};
|
||||||
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
|
/* Begin PBXProject section */
|
||||||
|
7F3DA6032F117B11003E4214 /* Project object */ = {
|
||||||
|
isa = PBXProject;
|
||||||
|
attributes = {
|
||||||
|
BuildIndependentTargetsInParallel = 1;
|
||||||
|
LastSwiftUpdateCheck = 2620;
|
||||||
|
LastUpgradeCheck = 2620;
|
||||||
|
TargetAttributes = {
|
||||||
|
7F3DA60A2F117B11003E4214 = {
|
||||||
|
CreatedOnToolsVersion = 26.2;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
buildConfigurationList = 7F3DA6062F117B11003E4214 /* Build configuration list for PBXProject "CheapTeleprompter" */;
|
||||||
|
developmentRegion = en;
|
||||||
|
hasScannedForEncodings = 0;
|
||||||
|
knownRegions = (
|
||||||
|
en,
|
||||||
|
Base,
|
||||||
|
);
|
||||||
|
mainGroup = 7F3DA6022F117B11003E4214;
|
||||||
|
minimizedProjectReferenceProxies = 1;
|
||||||
|
preferredProjectObjectVersion = 77;
|
||||||
|
productRefGroup = 7F3DA60C2F117B11003E4214 /* Products */;
|
||||||
|
projectDirPath = "";
|
||||||
|
projectRoot = "";
|
||||||
|
targets = (
|
||||||
|
7F3DA60A2F117B11003E4214 /* CheapTeleprompter */,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
/* End PBXProject section */
|
||||||
|
|
||||||
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
|
7F3DA6092F117B11003E4214 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
7FEDCFFB2F1972C10022FFE5 /* Subscriptions.storekit in Resources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
7F3DA6072F117B11003E4214 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin XCBuildConfiguration section */
|
||||||
|
7F3DA6142F117B11003E4214 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
|
DEVELOPMENT_TEAM = 7X85543FQQ;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_TESTABILITY = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
|
GCC_DYNAMIC_NO_PIC = NO;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_OPTIMIZATION_LEVEL = 0;
|
||||||
|
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||||
|
"DEBUG=1",
|
||||||
|
"$(inherited)",
|
||||||
|
);
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||||
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
7F3DA6152F117B11003E4214 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
|
DEVELOPMENT_TEAM = 7X85543FQQ;
|
||||||
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||||
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
|
VALIDATE_PRODUCT = YES;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
7F3DA6172F117B11003E4214 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 2;
|
||||||
|
DEVELOPMENT_TEAM = 7X85543FQQ;
|
||||||
|
ENABLE_PREVIEWS = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = "Cheap Teleprompter";
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
|
INFOPLIST_KEY_NSCameraUsageDescription = "CheapTeleprompter needs camera access to show your self-view behind the scrolling script.";
|
||||||
|
INFOPLIST_KEY_NSMicrophoneUsageDescription = "CheapTeleprompter needs microphone access to record audio with your video.";
|
||||||
|
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "CheapTeleprompter needs permission to save recorded videos to your Photos library.";
|
||||||
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 1.2;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.jaredlog.CheapTeleprompter;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
SUPPORTS_MACCATALYST = NO;
|
||||||
|
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
|
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
7F3DA6182F117B11003E4214 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 2;
|
||||||
|
DEVELOPMENT_TEAM = 7X85543FQQ;
|
||||||
|
ENABLE_PREVIEWS = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = "Cheap Teleprompter";
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
|
INFOPLIST_KEY_NSCameraUsageDescription = "CheapTeleprompter needs camera access to show your self-view behind the scrolling script.";
|
||||||
|
INFOPLIST_KEY_NSMicrophoneUsageDescription = "CheapTeleprompter needs microphone access to record audio with your video.";
|
||||||
|
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "CheapTeleprompter needs permission to save recorded videos to your Photos library.";
|
||||||
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 1.2;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.jaredlog.CheapTeleprompter;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
SUPPORTS_MACCATALYST = NO;
|
||||||
|
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
|
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
|
/* Begin XCConfigurationList section */
|
||||||
|
7F3DA6062F117B11003E4214 /* Build configuration list for PBXProject "CheapTeleprompter" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
7F3DA6142F117B11003E4214 /* Debug */,
|
||||||
|
7F3DA6152F117B11003E4214 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
7F3DA6162F117B11003E4214 /* Build configuration list for PBXNativeTarget "CheapTeleprompter" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
7F3DA6172F117B11003E4214 /* Debug */,
|
||||||
|
7F3DA6182F117B11003E4214 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
/* End XCConfigurationList section */
|
||||||
|
};
|
||||||
|
rootObject = 7F3DA6032F117B11003E4214 /* Project object */;
|
||||||
|
}
|
||||||
7
CheapTeleprompter.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "self:">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "2620"
|
||||||
|
version = "1.3">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "7F3DA60A2F117B11003E4214"
|
||||||
|
BuildableName = "CheapTeleprompter.app"
|
||||||
|
BlueprintName = "CheapTeleprompter"
|
||||||
|
ReferencedContainer = "container:CheapTeleprompter.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||||
|
<Testables>
|
||||||
|
</Testables>
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "7F3DA60A2F117B11003E4214"
|
||||||
|
BuildableName = "CheapTeleprompter.app"
|
||||||
|
BlueprintName = "CheapTeleprompter"
|
||||||
|
ReferencedContainer = "container:CheapTeleprompter.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
<StoreKitConfigurationFileReference
|
||||||
|
identifier = "../../Subscriptions.storekit">
|
||||||
|
</StoreKitConfigurationFileReference>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "7F3DA60A2F117B11003E4214"
|
||||||
|
BuildableName = "CheapTeleprompter.app"
|
||||||
|
BlueprintName = "CheapTeleprompter"
|
||||||
|
ReferencedContainer = "container:CheapTeleprompter.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/100.png
Normal file
|
After Width: | Height: | Size: 9.9 KiB |
BIN
CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/1024.png
Normal file
|
After Width: | Height: | Size: 190 KiB |
BIN
CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/114.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/120.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/144.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/152.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/167.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/180.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/20.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/29.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/40.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/50.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/57.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/58.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/60.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/72.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/76.png
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
BIN
CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/80.png
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
BIN
CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/87.png
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
@@ -0,0 +1 @@
|
|||||||
|
{"images":[{"size":"60x60","expected-size":"180","filename":"180.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"40x40","expected-size":"80","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"40x40","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"60x60","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"57x57","expected-size":"57","filename":"57.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"29x29","expected-size":"29","filename":"29.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"87","filename":"87.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"57x57","expected-size":"114","filename":"114.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"60","filename":"60.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"1024x1024","filename":"1024.png","expected-size":"1024","idiom":"ios-marketing","folder":"Assets.xcassets/AppIcon.appiconset/","scale":"1x"},{"size":"40x40","expected-size":"80","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"72x72","expected-size":"72","filename":"72.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"76x76","expected-size":"152","filename":"152.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"50x50","expected-size":"100","filename":"100.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"76x76","expected-size":"76","filename":"76.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"29x29","expected-size":"29","filename":"29.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"50x50","expected-size":"50","filename":"50.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"72x72","expected-size":"144","filename":"144.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"40x40","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"83.5x83.5","expected-size":"167","filename":"167.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"20x20","expected-size":"20","filename":"20.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"20x20","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"}]}
|
||||||
6
CheapTeleprompter/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
21
CheapTeleprompter/Assets.xcassets/SubscriptionIcon.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "subscription_icon.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
CheapTeleprompter/Assets.xcassets/SubscriptionIcon.imageset/subscription_icon.png
vendored
Normal file
|
After Width: | Height: | Size: 500 KiB |
231
CheapTeleprompter/CameraManager.swift
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
|
||||||
|
import Foundation
|
||||||
|
import AVFoundation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
class CameraManager: NSObject, AVCaptureFileOutputRecordingDelegate {
|
||||||
|
static let shared = CameraManager()
|
||||||
|
|
||||||
|
// Serial queue for all camera operations
|
||||||
|
private let cameraQueue = DispatchQueue(label: "com.cheapteleprompter.cameraQueue")
|
||||||
|
|
||||||
|
var session: AVCaptureSession?
|
||||||
|
private var movieOutput: AVCaptureMovieFileOutput?
|
||||||
|
private var rotationCoordinator: AVCaptureDevice.RotationCoordinator?
|
||||||
|
|
||||||
|
// Cached preview layer
|
||||||
|
private var _previewLayer: AVCaptureVideoPreviewLayer?
|
||||||
|
|
||||||
|
var previewLayer: AVCaptureVideoPreviewLayer {
|
||||||
|
if let layer = _previewLayer {
|
||||||
|
return layer
|
||||||
|
}
|
||||||
|
|
||||||
|
let layer = AVCaptureVideoPreviewLayer(session: session ?? AVCaptureSession())
|
||||||
|
layer.videoGravity = .resizeAspectFill
|
||||||
|
_previewLayer = layer
|
||||||
|
return layer
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delegate to report recording finish
|
||||||
|
var onRecordingFinished: ((URL) -> Void)?
|
||||||
|
|
||||||
|
// State to prevent double-configuration
|
||||||
|
private var isConfiguring = false
|
||||||
|
|
||||||
|
private override init() {
|
||||||
|
super.init()
|
||||||
|
setupSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupSession() {
|
||||||
|
print("[CameraManager] setupSession() called")
|
||||||
|
|
||||||
|
// Prevent concurrent setups
|
||||||
|
if isConfiguring {
|
||||||
|
print("[CameraManager] setupSession() IGNORED - already configuring")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check if session exists (unless we are forced to recreate, but recreate calls this.
|
||||||
|
// If session exists, we shouldn't be here unless specific tear down happened)
|
||||||
|
if session != nil {
|
||||||
|
print("[CameraManager] setupSession() IGNORED - session already exists")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isConfiguring = true
|
||||||
|
cameraQueue.async { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
print("[CameraManager] Beginning session configuration")
|
||||||
|
|
||||||
|
let session = AVCaptureSession()
|
||||||
|
session.beginConfiguration()
|
||||||
|
// Using .high for best video quality
|
||||||
|
session.sessionPreset = .high
|
||||||
|
|
||||||
|
// Camera Input
|
||||||
|
guard let videoDevice = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front),
|
||||||
|
let videoInput = try? AVCaptureDeviceInput(device: videoDevice) else {
|
||||||
|
print("[CameraManager] Failed to get video device or input")
|
||||||
|
session.commitConfiguration()
|
||||||
|
self.isConfiguring = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if session.canAddInput(videoInput) {
|
||||||
|
session.addInput(videoInput)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Microphone Input
|
||||||
|
if let audioDevice = AVCaptureDevice.default(for: .audio),
|
||||||
|
let audioInput = try? AVCaptureDeviceInput(device: audioDevice) {
|
||||||
|
if session.canAddInput(audioInput) {
|
||||||
|
session.addInput(audioInput)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Movie Output
|
||||||
|
let movieOutput = AVCaptureMovieFileOutput()
|
||||||
|
if session.canAddOutput(movieOutput) {
|
||||||
|
session.addOutput(movieOutput)
|
||||||
|
}
|
||||||
|
self.movieOutput = movieOutput
|
||||||
|
|
||||||
|
session.commitConfiguration()
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
// Create rotation coordinator for automatic orientation handling
|
||||||
|
self.rotationCoordinator = AVCaptureDevice.RotationCoordinator(
|
||||||
|
device: videoDevice,
|
||||||
|
previewLayer: nil // No preview layer needed for capture rotation
|
||||||
|
)
|
||||||
|
// Update cached preview layer to use the new session immediately
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self._previewLayer?.session = session
|
||||||
|
}
|
||||||
|
self.isConfiguring = false // Reset flag
|
||||||
|
print("[CameraManager] Session configuration committed")
|
||||||
|
|
||||||
|
// Preview Layer is now managed locally by CameraPreviewView to prevent lifecycle conflicts.
|
||||||
|
// CameraManager is strictly a Session Manager.
|
||||||
|
|
||||||
|
// Add Session Observers
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
NotificationCenter.default.addObserver(forName: AVCaptureSession.runtimeErrorNotification, object: session, queue: .main) { notification in
|
||||||
|
if let error = notification.userInfo?[AVCaptureSessionErrorKey] as? Error {
|
||||||
|
print("[CameraManager] !!! SESSION RUNTIME ERROR: \(error.localizedDescription) !!!")
|
||||||
|
} else {
|
||||||
|
print("[CameraManager] !!! SESSION RUNTIME ERROR (Unknown) !!!")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NotificationCenter.default.addObserver(forName: AVCaptureSession.wasInterruptedNotification, object: session, queue: .main) { notification in
|
||||||
|
if let reasonVal = notification.userInfo?[AVCaptureSessionInterruptionReasonKey] as? Int,
|
||||||
|
let reason = AVCaptureSession.InterruptionReason(rawValue: reasonVal) {
|
||||||
|
print("[CameraManager] !!! SESSION INTERRUPTED: Reason \(reason.rawValue) !!!")
|
||||||
|
} else {
|
||||||
|
print("[CameraManager] !!! SESSION INTERRUPTED (Unknown Reason) !!!")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NotificationCenter.default.addObserver(forName: AVCaptureSession.didStartRunningNotification, object: session, queue: .main) { _ in
|
||||||
|
print("[CameraManager] Session Did Start Running")
|
||||||
|
}
|
||||||
|
|
||||||
|
NotificationCenter.default.addObserver(forName: AVCaptureSession.didStopRunningNotification, object: session, queue: .main) { _ in
|
||||||
|
print("[CameraManager] Session Did Stop Running")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RotationCoordinator is now used to automatically determine correct rotation for each device
|
||||||
|
|
||||||
|
// MARK: - Session Control
|
||||||
|
|
||||||
|
func startSession() {
|
||||||
|
cameraQueue.async { [weak self] in
|
||||||
|
guard let self = self, let session = self.session, !session.isRunning else { return }
|
||||||
|
session.startRunning()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopSession() {
|
||||||
|
cameraQueue.async { [weak self] in
|
||||||
|
guard let self = self, let session = self.session, session.isRunning else { return }
|
||||||
|
session.stopRunning()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Completely tear down the session and release all resources
|
||||||
|
func teardownSession() {
|
||||||
|
print("[CameraManager] teardownSession() called")
|
||||||
|
cameraQueue.async { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
|
||||||
|
// Stop if running
|
||||||
|
if let session = self.session, session.isRunning {
|
||||||
|
session.stopRunning()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove all notification observers
|
||||||
|
if let session = self.session {
|
||||||
|
NotificationCenter.default.removeObserver(self, name: AVCaptureSession.runtimeErrorNotification, object: session)
|
||||||
|
NotificationCenter.default.removeObserver(self, name: AVCaptureSession.wasInterruptedNotification, object: session)
|
||||||
|
NotificationCenter.default.removeObserver(self, name: AVCaptureSession.didStartRunningNotification, object: session)
|
||||||
|
NotificationCenter.default.removeObserver(self, name: AVCaptureSession.didStopRunningNotification, object: session)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear references
|
||||||
|
self.movieOutput = nil
|
||||||
|
self.session = nil
|
||||||
|
self._previewLayer = nil
|
||||||
|
print("[CameraManager] teardownSession() complete")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recreate a fresh session from scratch
|
||||||
|
func recreateSession() {
|
||||||
|
// Call setupSession which creates everything new
|
||||||
|
setupSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
func startRecording() {
|
||||||
|
cameraQueue.async { [weak self] in
|
||||||
|
guard let self = self, let movieOutput = self.movieOutput, !movieOutput.isRecording else { return }
|
||||||
|
|
||||||
|
// Set video orientation using RotationCoordinator for device-aware rotation
|
||||||
|
if let connection = movieOutput.connection(with: .video),
|
||||||
|
let rotationCoordinator = self.rotationCoordinator {
|
||||||
|
let rotationAngle = rotationCoordinator.videoRotationAngleForHorizonLevelCapture
|
||||||
|
connection.videoRotationAngle = rotationAngle
|
||||||
|
}
|
||||||
|
|
||||||
|
let tempDir = FileManager.default.temporaryDirectory
|
||||||
|
let fileName = "teleprompter_rec_\(Date().timeIntervalSince1970).mov"
|
||||||
|
let fileURL = tempDir.appendingPathComponent(fileName)
|
||||||
|
|
||||||
|
movieOutput.startRecording(to: fileURL, recordingDelegate: self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopRecording() {
|
||||||
|
cameraQueue.async { [weak self] in
|
||||||
|
guard let self = self, let movieOutput = self.movieOutput, movieOutput.isRecording else { return }
|
||||||
|
movieOutput.stopRecording()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AVCaptureFileOutputRecordingDelegate
|
||||||
|
nonisolated func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error?) {
|
||||||
|
if let error = error {
|
||||||
|
print("Error recording: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.onRecordingFinished?(outputFileURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
184
CheapTeleprompter/CameraPreviewView.swift
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
//
|
||||||
|
// CameraPreviewView.swift
|
||||||
|
// CheapTeleprompter
|
||||||
|
//
|
||||||
|
// Created by Jared Evans on 1/9/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import AVFoundation
|
||||||
|
|
||||||
|
/// A UIViewRepresentable that displays a live camera preview
|
||||||
|
struct CameraPreviewView: UIViewRepresentable {
|
||||||
|
|
||||||
|
class CameraView: UIView {
|
||||||
|
var previewLayer: AVCaptureVideoPreviewLayer?
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
print("[CameraView] init frame: \(frame)")
|
||||||
|
|
||||||
|
// iPad: Listen for device orientation changes to update preview rotation
|
||||||
|
if UIDevice.current.userInterfaceIdiom == .pad {
|
||||||
|
UIDevice.current.beginGeneratingDeviceOrientationNotifications()
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(deviceOrientationDidChange),
|
||||||
|
name: UIDevice.orientationDidChangeNotification,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
super.init(coder: coder)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func deviceOrientationDidChange() {
|
||||||
|
updatePreviewRotationForIPad()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updatePreviewRotationForIPad() {
|
||||||
|
guard UIDevice.current.userInterfaceIdiom == .pad,
|
||||||
|
let connection = previewLayer?.connection else { return }
|
||||||
|
|
||||||
|
let orientation = UIDevice.current.orientation
|
||||||
|
var rotationAngle: CGFloat = 90 // Default to portrait
|
||||||
|
|
||||||
|
switch orientation {
|
||||||
|
case .portrait:
|
||||||
|
rotationAngle = 90
|
||||||
|
case .portraitUpsideDown:
|
||||||
|
rotationAngle = 270
|
||||||
|
case .landscapeLeft:
|
||||||
|
rotationAngle = 180
|
||||||
|
case .landscapeRight:
|
||||||
|
rotationAngle = 0
|
||||||
|
case .faceUp, .faceDown, .unknown:
|
||||||
|
return // Don't change rotation for these
|
||||||
|
@unknown default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if connection.isVideoRotationAngleSupported(rotationAngle) {
|
||||||
|
connection.videoRotationAngle = rotationAngle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func configureLayer() {
|
||||||
|
print("[CameraView] configureLayer() called")
|
||||||
|
|
||||||
|
// Ensure session exists before attaching layer
|
||||||
|
if CameraManager.shared.session == nil {
|
||||||
|
print("[CameraView] No session found in CameraManager, retrying in 0.1s...")
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
|
||||||
|
self?.configureLayer()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use cached layer from manager
|
||||||
|
let newLayer = CameraManager.shared.previewLayer
|
||||||
|
|
||||||
|
// Check if already added
|
||||||
|
if newLayer.superlayer == layer {
|
||||||
|
print("[CameraView] Layer already added, skipping")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from any previous superlayer (e.g. if we are restoring)
|
||||||
|
newLayer.removeFromSuperlayer()
|
||||||
|
|
||||||
|
newLayer.frame = bounds
|
||||||
|
|
||||||
|
layer.addSublayer(newLayer)
|
||||||
|
self.previewLayer = newLayer
|
||||||
|
|
||||||
|
print("[CameraView] Cached previewLayer attached: \(newLayer)")
|
||||||
|
|
||||||
|
// Apply initial iPad rotation
|
||||||
|
updatePreviewRotationForIPad()
|
||||||
|
|
||||||
|
// Ensure session is running
|
||||||
|
CameraManager.shared.startSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func layoutSubviews() {
|
||||||
|
super.layoutSubviews()
|
||||||
|
|
||||||
|
if let previewLayer = previewLayer {
|
||||||
|
// Frame optimization: Only update frame if it changed
|
||||||
|
if previewLayer.frame != bounds {
|
||||||
|
CATransaction.begin()
|
||||||
|
CATransaction.setDisableActions(true)
|
||||||
|
previewLayer.frame = bounds
|
||||||
|
CATransaction.commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// iPad rotation is handled via notification observer
|
||||||
|
} else {
|
||||||
|
configureLayer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanup() {
|
||||||
|
print("[CameraView] cleanup() called")
|
||||||
|
if let previewLayer = previewLayer {
|
||||||
|
print("[CameraView] Detaching previewLayer")
|
||||||
|
previewLayer.removeFromSuperlayer()
|
||||||
|
self.previewLayer = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
print("[CameraView] deinit")
|
||||||
|
cleanup()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Binding var isRecording: Bool
|
||||||
|
var onRecordingFinished: ((URL) -> Void)
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator {
|
||||||
|
Coordinator()
|
||||||
|
}
|
||||||
|
|
||||||
|
class Coordinator {
|
||||||
|
var lastRecordingState: Bool = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeUIView(context: Context) -> CameraView {
|
||||||
|
print("[CameraPreviewView] makeUIView called")
|
||||||
|
let view = CameraView(frame: .zero)
|
||||||
|
CameraManager.shared.onRecordingFinished = { url in
|
||||||
|
onRecordingFinished(url)
|
||||||
|
}
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIView(_ uiView: CameraView, context: Context) {
|
||||||
|
// Only trigger recording changes when state actually changes
|
||||||
|
guard context.coordinator.lastRecordingState != isRecording else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
print("[CameraPreviewView] Recording state changed: \(context.coordinator.lastRecordingState) -> \(isRecording)")
|
||||||
|
context.coordinator.lastRecordingState = isRecording
|
||||||
|
|
||||||
|
if isRecording {
|
||||||
|
CameraManager.shared.startRecording()
|
||||||
|
} else {
|
||||||
|
CameraManager.shared.stopRecording()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func dismantleUIView(_ uiView: CameraView, coordinator: Coordinator) {
|
||||||
|
print("[CameraPreviewView] dismantleUIView called")
|
||||||
|
uiView.cleanup()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
CameraPreviewView(isRecording: .constant(false)) { _ in }
|
||||||
|
.ignoresSafeArea()
|
||||||
|
}
|
||||||
140
CheapTeleprompter/CameraWindowManager.swift
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
|
||||||
|
import UIKit
|
||||||
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
class CameraWindowManager: ObservableObject {
|
||||||
|
static let shared = CameraWindowManager()
|
||||||
|
|
||||||
|
private var cameraWindow: UIWindow?
|
||||||
|
|
||||||
|
// Published state for UI to observe restoration status
|
||||||
|
@Published var isRestoring: Bool = false
|
||||||
|
|
||||||
|
// Store these for recreating after rotation
|
||||||
|
private var storedIsRecording: Binding<Bool>?
|
||||||
|
private var storedOnRecordingFinished: ((URL) -> Void)?
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
func show(isRecording: Binding<Bool>, onRecordingFinished: @escaping (URL) -> Void) {
|
||||||
|
|
||||||
|
// Ensure session exists (it might have been torn down)
|
||||||
|
if CameraManager.shared.session == nil {
|
||||||
|
CameraManager.shared.recreateSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store for potential recreation after rotation
|
||||||
|
storedIsRecording = isRecording
|
||||||
|
storedOnRecordingFinished = onRecordingFinished
|
||||||
|
|
||||||
|
createWindow()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createWindow() {
|
||||||
|
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let isRecording = storedIsRecording,
|
||||||
|
let onRecordingFinished = storedOnRecordingFinished else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if cameraWindow == nil {
|
||||||
|
let window = UIWindow(windowScene: windowScene)
|
||||||
|
window.windowLevel = .normal - 1 // Behind the main window
|
||||||
|
window.backgroundColor = .black // Camera background
|
||||||
|
|
||||||
|
let rootView = CameraPreviewView(isRecording: isRecording, onRecordingFinished: onRecordingFinished)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
let rootVC = PortraitHostingController(rootView: rootView)
|
||||||
|
rootVC.view.backgroundColor = .clear
|
||||||
|
|
||||||
|
window.rootViewController = rootVC
|
||||||
|
self.cameraWindow = window
|
||||||
|
}
|
||||||
|
|
||||||
|
cameraWindow?.isHidden = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func hide() {
|
||||||
|
// Explicitly TEARDOWN the session to free ALL resources and prevent background conflicts
|
||||||
|
// stopSession() was not enough to prevent Signal 9 on rotation
|
||||||
|
CameraManager.shared.teardownSession()
|
||||||
|
destroyWindow()
|
||||||
|
storedIsRecording = nil
|
||||||
|
storedIsRecording = nil
|
||||||
|
storedOnRecordingFinished = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func destroyWindow() {
|
||||||
|
cameraWindow?.isHidden = true
|
||||||
|
cameraWindow?.rootViewController = nil
|
||||||
|
cameraWindow = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Temporarily store rootVC during rotation
|
||||||
|
private var storedRootVC: UIViewController?
|
||||||
|
|
||||||
|
|
||||||
|
/// Hide camera window and remove rootVC during rotation
|
||||||
|
func destroyForRotation() {
|
||||||
|
|
||||||
|
// Safety check: if window is already nil (e.g. video player is open), ignore this
|
||||||
|
guard let window = cameraWindow, !window.isHidden else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Just hide the window. Do NOT detach the rootViewController.
|
||||||
|
// Detaching it causes the SwiftUI view to be dismantled, which triggers
|
||||||
|
// layer cleanup and recreation (Signal 9 crash risk).
|
||||||
|
// PERSISTENT SESSION STRATEGY: Stop the session but keep it in memory.
|
||||||
|
// We destroy the window to prevent layout crashes, but reuse the session to prevent Signal 9 resource exhaustion.
|
||||||
|
CameraWindowManager.shared.isRestoring = true
|
||||||
|
CameraManager.shared.stopSession()
|
||||||
|
destroyWindow()
|
||||||
|
|
||||||
|
storedRootVC = nil // We don't need this anymore as we are destroying the window
|
||||||
|
// print("[CameraWindowManager] Window and Session destroyed checking for rotation")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore camera window after rotation
|
||||||
|
func recreateAfterRotation() {
|
||||||
|
print("[CameraWindowManager] Recreating session and window after rotation")
|
||||||
|
|
||||||
|
// 1. Recreate only if we have the necessary bindings (safety check)
|
||||||
|
guard let isRecordingBinder = storedIsRecording,
|
||||||
|
let onFinishedCallback = storedOnRecordingFinished else {
|
||||||
|
print("[CameraWindowManager] Cannot recreate: missing bindings")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Reuse existing session (do NOT recreate)
|
||||||
|
// CameraManager.shared.recreateSession() // Removed
|
||||||
|
|
||||||
|
// 3. Show the window (creates new VC and View)
|
||||||
|
show(isRecording: isRecordingBinder, onRecordingFinished: onFinishedCallback)
|
||||||
|
|
||||||
|
// 4. Restart the session
|
||||||
|
CameraManager.shared.startSession()
|
||||||
|
|
||||||
|
CameraWindowManager.shared.isRestoring = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A UIHostingController that enforces Portrait orientation
|
||||||
|
class PortraitHostingController<Content: View>: UIHostingController<Content> {
|
||||||
|
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
|
||||||
|
return .portrait
|
||||||
|
}
|
||||||
|
|
||||||
|
override var shouldAutorotate: Bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLayoutSubviews() {
|
||||||
|
super.viewDidLayoutSubviews()
|
||||||
|
}
|
||||||
|
}
|
||||||
75
CheapTeleprompter/CheapTeleprompterApp.swift
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
//
|
||||||
|
// CheapTeleprompterApp.swift
|
||||||
|
// CheapTeleprompter
|
||||||
|
//
|
||||||
|
// Created by Jared Evans on 1/9/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import AVFoundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
// Global orientation lock state
|
||||||
|
class OrientationLock: ObservableObject {
|
||||||
|
static let shared = OrientationLock()
|
||||||
|
|
||||||
|
@Published var isLocked = false
|
||||||
|
var lockedOrientation: UIInterfaceOrientationMask = .all
|
||||||
|
|
||||||
|
func lock(to orientation: UIInterfaceOrientationMask = .portrait) {
|
||||||
|
isLocked = true
|
||||||
|
lockedOrientation = orientation
|
||||||
|
|
||||||
|
// Force orientation update
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { return }
|
||||||
|
windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: orientation)) { error in
|
||||||
|
print("[OrientationLock] Geometry update error: \(error)")
|
||||||
|
}
|
||||||
|
// Force all view controllers to re-check supported orientations
|
||||||
|
for window in windowScene.windows {
|
||||||
|
window.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func unlock() {
|
||||||
|
isLocked = false
|
||||||
|
lockedOrientation = .all
|
||||||
|
|
||||||
|
// Allow all orientations again
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { return }
|
||||||
|
for window in windowScene.windows {
|
||||||
|
window.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// App delegate to handle orientation
|
||||||
|
class AppDelegate: NSObject, UIApplicationDelegate {
|
||||||
|
func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask {
|
||||||
|
if OrientationLock.shared.isLocked {
|
||||||
|
return OrientationLock.shared.lockedOrientation
|
||||||
|
}
|
||||||
|
return .all
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct CheapTeleprompterApp: App {
|
||||||
|
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||||
|
@StateObject private var subscriptionManager = SubscriptionManager()
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Audio session configuration moved to CameraManager
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
ContentView()
|
||||||
|
.environmentObject(subscriptionManager)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
CheapTeleprompter/ContentView.swift
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
//
|
||||||
|
// ContentView.swift
|
||||||
|
// CheapTeleprompter
|
||||||
|
//
|
||||||
|
// Created by Jared Evans on 1/9/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ContentView: View {
|
||||||
|
var body: some View {
|
||||||
|
ScriptEditorView()
|
||||||
|
.background(Color.clear)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
ContentView()
|
||||||
|
}
|
||||||
55
CheapTeleprompter/RotationDetector.swift
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
struct RotationDetector: UIViewControllerRepresentable {
|
||||||
|
func makeUIViewController(context: Context) -> RotationDetectorViewController {
|
||||||
|
return RotationDetectorViewController()
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_ uiViewController: RotationDetectorViewController, context: Context) {
|
||||||
|
}
|
||||||
|
|
||||||
|
class RotationDetectorViewController: UIViewController {
|
||||||
|
private var pendingRestoreTask: DispatchWorkItem?
|
||||||
|
private var isWindowDestroyed = false
|
||||||
|
|
||||||
|
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
|
||||||
|
super.viewWillTransition(to: size, with: coordinator)
|
||||||
|
|
||||||
|
// Cancel any pending restore - new rotation is starting
|
||||||
|
pendingRestoreTask?.cancel()
|
||||||
|
pendingRestoreTask = nil
|
||||||
|
|
||||||
|
// Only destroy once per rotation sequence
|
||||||
|
if !isWindowDestroyed {
|
||||||
|
isWindowDestroyed = true
|
||||||
|
CameraWindowManager.shared.destroyForRotation()
|
||||||
|
|
||||||
|
// Notify UI that camera is restoring
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
CameraWindowManager.shared.isRestoring = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
coordinator.animate(alongsideTransition: nil) { [weak self] _ in
|
||||||
|
guard let self = self else { return }
|
||||||
|
|
||||||
|
// Cancel any previously scheduled restore
|
||||||
|
self.pendingRestoreTask?.cancel()
|
||||||
|
|
||||||
|
let task = DispatchWorkItem { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.isWindowDestroyed = false
|
||||||
|
CameraWindowManager.shared.recreateAfterRotation()
|
||||||
|
|
||||||
|
// Notify UI that restoration is complete
|
||||||
|
CameraWindowManager.shared.isRestoring = false
|
||||||
|
}
|
||||||
|
|
||||||
|
self.pendingRestoreTask = task
|
||||||
|
// Wait 1.0s after rotation completes before showing camera
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0, execute: task)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
129
CheapTeleprompter/ScriptEditorView.swift
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
//
|
||||||
|
// ScriptEditorView.swift
|
||||||
|
// CheapTeleprompter
|
||||||
|
//
|
||||||
|
// Created by Jared Evans on 1/9/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ScriptEditorView: View {
|
||||||
|
@AppStorage("teleprompterScript") private var script: String = """
|
||||||
|
Welcome to CheapTeleprompter!
|
||||||
|
|
||||||
|
Paste or type your script here. When you're ready, tap the "Show Teleprompter" button below.
|
||||||
|
|
||||||
|
The script will scroll automatically over your camera preview so you can read while recording yourself.
|
||||||
|
|
||||||
|
Tips:
|
||||||
|
• Use short paragraphs for easier reading
|
||||||
|
• Adjust scroll speed to match your pace
|
||||||
|
• Pause anytime by tapping the screen
|
||||||
|
"""
|
||||||
|
|
||||||
|
@State private var showTeleprompter = false
|
||||||
|
@State private var showSubscriptionSheet = false
|
||||||
|
@EnvironmentObject var subscriptionManager: SubscriptionManager
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if showTeleprompter {
|
||||||
|
// If teleprompter is active, show only that
|
||||||
|
// This removes the Editor view from the hierarchy completely,
|
||||||
|
// allowing the transparent background to reveal the camera window behind it.
|
||||||
|
TeleprompterView(isPresented: $showTeleprompter, script: script)
|
||||||
|
.transition(.opacity)
|
||||||
|
.background(Color.clear) // Ensure transparency
|
||||||
|
} else {
|
||||||
|
// Otherwise show the editor
|
||||||
|
NavigationStack {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// ... content ...
|
||||||
|
// Script editor
|
||||||
|
TextEditor(text: $script)
|
||||||
|
.font(.system(size: 18))
|
||||||
|
.padding()
|
||||||
|
.background(Color(.systemBackground))
|
||||||
|
|
||||||
|
// Bottom bar with start button
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Button(action: {
|
||||||
|
withAnimation {
|
||||||
|
showTeleprompter = true
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "play.fill")
|
||||||
|
Text("Show Teleprompter")
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 16)
|
||||||
|
.background(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [.blue, .purple],
|
||||||
|
startPoint: .leading,
|
||||||
|
endPoint: .trailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.cornerRadius(12)
|
||||||
|
}
|
||||||
|
.disabled(script.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.secondarySystemBackground))
|
||||||
|
|
||||||
|
// Subscription Link
|
||||||
|
if !subscriptionManager.isSubscribed {
|
||||||
|
Button(action: {
|
||||||
|
showSubscriptionSheet = true
|
||||||
|
}) {
|
||||||
|
Text("Unlock Unlimited Time")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Text("You have unlimited time.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Button(action: {
|
||||||
|
showSubscriptionSheet = true
|
||||||
|
}) {
|
||||||
|
Text("Manage Subscription")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showSubscriptionSheet) {
|
||||||
|
SubscriptionSheet()
|
||||||
|
}
|
||||||
|
.navigationTitle("Edit Script")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button(action: {
|
||||||
|
script = ""
|
||||||
|
}) {
|
||||||
|
Image(systemName: "trash")
|
||||||
|
.foregroundColor(.red)
|
||||||
|
}
|
||||||
|
.disabled(script.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.transition(.opacity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(Color.clear) // Ensure Group container doesn't add background
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
ScriptEditorView()
|
||||||
|
}
|
||||||
104
CheapTeleprompter/SubscriptionManager.swift
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
//
|
||||||
|
// SubscriptionManager.swift
|
||||||
|
// CheapTeleprompter
|
||||||
|
//
|
||||||
|
// Created by Jared Evans on 1/9/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import StoreKit
|
||||||
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class SubscriptionManager: ObservableObject {
|
||||||
|
@Published private(set) var products: [Product] = []
|
||||||
|
@Published private(set) var purchasedProductIDs: Set<String> = []
|
||||||
|
|
||||||
|
// Product ID from user request
|
||||||
|
private let productIDs = ["unlock_unlimited_time_teleprompting"]
|
||||||
|
|
||||||
|
var isSubscribed: Bool {
|
||||||
|
return !purchasedProductIDs.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
Task {
|
||||||
|
await updatePurchasedProducts()
|
||||||
|
await fetchProducts()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchProducts() async {
|
||||||
|
do {
|
||||||
|
let products = try await Product.products(for: productIDs)
|
||||||
|
self.products = products
|
||||||
|
} catch {
|
||||||
|
print("Failed to fetch products: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func purchase(_ product: Product) async throws {
|
||||||
|
let result = try await product.purchase()
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case .success(let verification):
|
||||||
|
// Check whether the transaction is verified. If it isn't,
|
||||||
|
// this function rethrows the verification error.
|
||||||
|
let transaction = try checkVerified(verification)
|
||||||
|
|
||||||
|
// The transaction is verified. Deliver content to the user.
|
||||||
|
await updatePurchasedProducts()
|
||||||
|
|
||||||
|
// Always finish a transaction.
|
||||||
|
await transaction.finish()
|
||||||
|
|
||||||
|
case .userCancelled:
|
||||||
|
break
|
||||||
|
|
||||||
|
case .pending:
|
||||||
|
break
|
||||||
|
|
||||||
|
@unknown default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func restorePurchases() async {
|
||||||
|
// App Store automatically synchronizes queue, but we can re-check current entitlements
|
||||||
|
try? await AppStore.sync()
|
||||||
|
await updatePurchasedProducts()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updatePurchasedProducts() async {
|
||||||
|
var purchased = Set<String>()
|
||||||
|
|
||||||
|
// Iterate through the user's current entitlements
|
||||||
|
for await result in Transaction.currentEntitlements {
|
||||||
|
guard case .verified(let transaction) = result else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if transaction.revocationDate == nil {
|
||||||
|
purchased.insert(transaction.productID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.purchasedProductIDs = purchased
|
||||||
|
}
|
||||||
|
|
||||||
|
private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
|
||||||
|
// Check whether the JWS passes StoreKit verification.
|
||||||
|
switch result {
|
||||||
|
case .unverified:
|
||||||
|
// StoreKit parses the JWS, but it fails verification.
|
||||||
|
throw StoreError.failedVerification
|
||||||
|
case .verified(let safe):
|
||||||
|
// The result is verified. Return the unwrapped value.
|
||||||
|
return safe
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum StoreError: Error {
|
||||||
|
case failedVerification
|
||||||
|
}
|
||||||
94
CheapTeleprompter/SubscriptionSheet.swift
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
//
|
||||||
|
// SubscriptionSheet.swift
|
||||||
|
// CheapTeleprompter
|
||||||
|
//
|
||||||
|
// Created by Jared Evans on 1/9/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import StoreKit
|
||||||
|
|
||||||
|
struct SubscriptionSheet: View {
|
||||||
|
@EnvironmentObject var subscriptionManager: SubscriptionManager
|
||||||
|
@Environment(\.dismiss) var dismiss
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
Image("SubscriptionIcon")
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(width: 160, height: 160)
|
||||||
|
.clipShape(Circle())
|
||||||
|
.shadow(radius: 10)
|
||||||
|
.padding(.top, 40)
|
||||||
|
|
||||||
|
Text("Unlock Unlimited Time")
|
||||||
|
.font(.title)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
|
||||||
|
Text("Record teleprompter videos\nlonger than 2 minutes.")
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.padding(.horizontal, 40) // Add more padding to force wrap if screen is wide, or just rely on newline
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
if subscriptionManager.isSubscribed {
|
||||||
|
// Subscribed user view
|
||||||
|
Text("You have unlimited time.")
|
||||||
|
.font(.headline)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
if let url = URL(string: "https://apps.apple.com/account/subscriptions") {
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Text("Manage Subscription")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
}
|
||||||
|
} else if let product = subscriptionManager.products.first {
|
||||||
|
Button(action: {
|
||||||
|
Task {
|
||||||
|
try? await subscriptionManager.purchase(product)
|
||||||
|
if subscriptionManager.isSubscribed {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
VStack {
|
||||||
|
Text("Subscribe \(product.displayPrice)/year")
|
||||||
|
.font(.headline)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding()
|
||||||
|
.background(Color.blue)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.cornerRadius(12)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button("Restore Purchases") {
|
||||||
|
Task {
|
||||||
|
await subscriptionManager.restorePurchases()
|
||||||
|
if subscriptionManager.isSubscribed {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
} else {
|
||||||
|
ProgressView()
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.presentationDetents([.medium])
|
||||||
|
}
|
||||||
|
}
|
||||||
712
CheapTeleprompter/TeleprompterView.swift
Normal file
@@ -0,0 +1,712 @@
|
|||||||
|
//
|
||||||
|
// TeleprompterView.swift
|
||||||
|
// CheapTeleprompter
|
||||||
|
//
|
||||||
|
// Created by Jared Evans on 1/9/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
// Orientation lock helper for camera view
|
||||||
|
class OrientationManager: ObservableObject {
|
||||||
|
static let shared = OrientationManager()
|
||||||
|
|
||||||
|
@Published var isLocked = false
|
||||||
|
var lockedOrientation: UIInterfaceOrientationMask = .all
|
||||||
|
|
||||||
|
func lock(to orientation: UIInterfaceOrientationMask) {
|
||||||
|
isLocked = true
|
||||||
|
lockedOrientation = orientation
|
||||||
|
|
||||||
|
// Force the orientation update
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { return }
|
||||||
|
windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: orientation))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func unlock() {
|
||||||
|
isLocked = false
|
||||||
|
lockedOrientation = .all
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// View modifier to lock orientation
|
||||||
|
struct OrientationLockedView<Content: View>: UIViewControllerRepresentable {
|
||||||
|
let content: Content
|
||||||
|
let orientation: UIInterfaceOrientationMask
|
||||||
|
|
||||||
|
init(orientation: UIInterfaceOrientationMask, @ViewBuilder content: () -> Content) {
|
||||||
|
self.orientation = orientation
|
||||||
|
self.content = content()
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeUIViewController(context: Context) -> OrientationLockedViewController<Content> {
|
||||||
|
OrientationLockedViewController(rootView: content, orientation: orientation)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_ uiViewController: OrientationLockedViewController<Content>, context: Context) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class OrientationLockedViewController<Content: View>: UIHostingController<Content> {
|
||||||
|
let lockedOrientation: UIInterfaceOrientationMask
|
||||||
|
|
||||||
|
init(rootView: Content, orientation: UIInterfaceOrientationMask) {
|
||||||
|
self.lockedOrientation = orientation
|
||||||
|
super.init(rootView: rootView)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor required dynamic init?(coder aDecoder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
|
||||||
|
return lockedOrientation
|
||||||
|
}
|
||||||
|
|
||||||
|
override var shouldAutorotate: Bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TeleprompterView: View {
|
||||||
|
@Binding var isPresented: Bool
|
||||||
|
var script: String
|
||||||
|
@State private var fontSize: CGFloat = 40 // Fixed: needs to be State
|
||||||
|
@AppStorage("scrollSpeed") private var scrollSpeed: Double = 50
|
||||||
|
|
||||||
|
@State private var scrollOffset: CGFloat = 0
|
||||||
|
@State private var isPlaying = false
|
||||||
|
@State private var showControls = true
|
||||||
|
@State private var timer: Timer?
|
||||||
|
@State private var controlsTimer: Timer?
|
||||||
|
|
||||||
|
// Recording state
|
||||||
|
@State private var isRecording = false
|
||||||
|
@State private var recordedVideoURL: URL?
|
||||||
|
@State private var showVideoPlayer = false
|
||||||
|
@State private var countdownValue: Int? = nil
|
||||||
|
|
||||||
|
// Subscription
|
||||||
|
@EnvironmentObject var subscriptionManager: SubscriptionManager
|
||||||
|
@State private var showLimitAlert = false
|
||||||
|
@State private var startTime: Date?
|
||||||
|
|
||||||
|
// Drag gesture state
|
||||||
|
@State private var dragStartOffset: CGFloat = 0
|
||||||
|
@State private var isDragging = false
|
||||||
|
@State private var textHeight: CGFloat = 0
|
||||||
|
@State private var containerHeight: CGFloat = 0
|
||||||
|
@State private var lastDragValue: CGFloat = 0 // Fixed: restored
|
||||||
|
|
||||||
|
// Observe camera window manager for restoring state
|
||||||
|
@ObservedObject private var cameraWindowManager = CameraWindowManager.shared
|
||||||
|
|
||||||
|
// Voice trigger
|
||||||
|
@StateObject private var voiceTriggerManager = VoiceTriggerManager()
|
||||||
|
@State private var isListeningForVoice = false
|
||||||
|
@State private var voicePulseAnimation = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
mainContent
|
||||||
|
.ignoresSafeArea()
|
||||||
|
// Camera is hosted in a separate background window via CameraWindowManager
|
||||||
|
// This prevents the camera view from being resized during rotation
|
||||||
|
.onAppear {
|
||||||
|
resetControlsTimer()
|
||||||
|
// SEPARATE WINDOW STRATEGY:
|
||||||
|
// Isolate camera in a Portrait-only window to prevent rotation resizing crashes.
|
||||||
|
// Only show if video player is NOT showing (to avoid conflicts)
|
||||||
|
if !showVideoPlayer {
|
||||||
|
CameraWindowManager.shared.show(isRecording: $isRecording) { url in
|
||||||
|
self.recordedVideoURL = url
|
||||||
|
self.showVideoPlayer = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
stopTimers()
|
||||||
|
voiceTriggerManager.stopListening()
|
||||||
|
CameraWindowManager.shared.hide()
|
||||||
|
}
|
||||||
|
.background {
|
||||||
|
// Transparent background to let the separate camera window show through
|
||||||
|
Color.clear.ignoresSafeArea()
|
||||||
|
|
||||||
|
// Only active when video player is NOT shown
|
||||||
|
if !showVideoPlayer {
|
||||||
|
RotationDetector().frame(width: 0, height: 0)
|
||||||
|
TransparencyEnforcer().frame(width: 0, height: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.persistentSystemOverlays(.hidden)
|
||||||
|
.fullScreenCover(isPresented: $showVideoPlayer) {
|
||||||
|
if let url = recordedVideoURL {
|
||||||
|
VideoPlayerView(videoURL: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: showVideoPlayer) { oldValue, newValue in
|
||||||
|
// Hide camera window when video player is shown to prevent resource conflicts
|
||||||
|
if newValue {
|
||||||
|
CameraWindowManager.shared.hide()
|
||||||
|
} else {
|
||||||
|
// Restore camera window when video player is dismissed
|
||||||
|
CameraWindowManager.shared.show(isRecording: $isRecording) { url in
|
||||||
|
self.recordedVideoURL = url
|
||||||
|
self.showVideoPlayer = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert("Free Limit Reached", isPresented: $showLimitAlert) {
|
||||||
|
Button("OK", role: .cancel) { }
|
||||||
|
} message: {
|
||||||
|
Text("Subscribe to unlock unlimited recording time.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var currentOrientationMask: UIInterfaceOrientationMask {
|
||||||
|
let orientation = UIDevice.current.orientation
|
||||||
|
switch orientation {
|
||||||
|
case .landscapeLeft:
|
||||||
|
return .landscapeRight // UIKit is inverted
|
||||||
|
case .landscapeRight:
|
||||||
|
return .landscapeLeft
|
||||||
|
case .portraitUpsideDown:
|
||||||
|
return .portraitUpsideDown
|
||||||
|
default:
|
||||||
|
return .portrait
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var mainContent: some View {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
ZStack {
|
||||||
|
// Transparent background - camera window is behind us
|
||||||
|
Color.clear
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
// Dark overlay to make script stand out against video
|
||||||
|
Color.black.opacity(0.4)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
// Extra Gradient overlay REMOVED
|
||||||
|
// VStack {
|
||||||
|
// LinearGradient(
|
||||||
|
// colors: [.black.opacity(0.6), .clear],
|
||||||
|
// startPoint: .top,
|
||||||
|
// endPoint: .bottom
|
||||||
|
// )
|
||||||
|
// .frame(height: geometry.size.height * 0.4)
|
||||||
|
//
|
||||||
|
// Spacer()
|
||||||
|
// }
|
||||||
|
// .ignoresSafeArea()
|
||||||
|
|
||||||
|
// Scrolling script text
|
||||||
|
GeometryReader { textGeometry in
|
||||||
|
VStack {
|
||||||
|
Text(script)
|
||||||
|
.font(.system(size: fontSize, weight: .medium))
|
||||||
|
.kerning(1.5)
|
||||||
|
.foregroundColor(Color(red: 1, green: 1, blue: 0)) // Pure Yellow for max brightness
|
||||||
|
.shadow(color: .black, radius: 1, x: 1, y: 1) // Optimized single shadow
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
// Dynamic padding: less in portrait (longer lines), more in landscape (shorter lines)
|
||||||
|
.padding(.leading, textGeometry.size.width > textGeometry.size.height ? 150 : 48)
|
||||||
|
.padding(.trailing, textGeometry.size.width > textGeometry.size.height ? 150 : 36)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.background(
|
||||||
|
GeometryReader { textSize in
|
||||||
|
Color.clear
|
||||||
|
.onAppear {
|
||||||
|
textHeight = textSize.size.height
|
||||||
|
}
|
||||||
|
.onChange(of: script) { _, _ in
|
||||||
|
textHeight = textSize.size.height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.frame(width: textGeometry.size.width)
|
||||||
|
// Start with text 3/4 up the screen (25% from top), scroll up to show all
|
||||||
|
.offset(y: (textGeometry.size.height * 0.25) - scrollOffset)
|
||||||
|
.onAppear {
|
||||||
|
containerHeight = textGeometry.size.height
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle()) // Ensure the whole area is draggable
|
||||||
|
.gesture(
|
||||||
|
DragGesture(minimumDistance: 20)
|
||||||
|
.onChanged { value in
|
||||||
|
let delta = value.translation.height - lastDragValue
|
||||||
|
scrollOffset -= delta
|
||||||
|
lastDragValue = value.translation.height
|
||||||
|
resetControlsTimer()
|
||||||
|
}
|
||||||
|
.onEnded { _ in
|
||||||
|
lastDragValue = 0
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3-2-1 Countdown Overlay
|
||||||
|
if let count = countdownValue {
|
||||||
|
Text("\(count)")
|
||||||
|
.font(.system(size: 150, weight: .bold))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.shadow(color: .black, radius: 4, x: 0, y: 2)
|
||||||
|
.transition(.scale.combined(with: .opacity))
|
||||||
|
.zIndex(100)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restoring Camera Indicator
|
||||||
|
if cameraWindowManager.isRestoring {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Text("Restoring")
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
Image(systemName: "camera.fill")
|
||||||
|
.font(.system(size: 16))
|
||||||
|
}
|
||||||
|
.foregroundColor(.black)
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(.white.opacity(0.9))
|
||||||
|
)
|
||||||
|
.transition(.opacity)
|
||||||
|
.zIndex(99)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Voice Trigger Waiting Indicator
|
||||||
|
if isListeningForVoice {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "mic.fill")
|
||||||
|
.font(.system(size: 16))
|
||||||
|
Text("Waiting for your voice trigger...")
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
}
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(.red.opacity(0.9))
|
||||||
|
)
|
||||||
|
.transition(.opacity)
|
||||||
|
.zIndex(99)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eye line indicators - static arrows to show focus line
|
||||||
|
HStack {
|
||||||
|
// Left arrow (pointing right)
|
||||||
|
Image(systemName: "arrowtriangle.right.fill")
|
||||||
|
.font(.system(size: 20))
|
||||||
|
.foregroundColor(.white.opacity(0.7))
|
||||||
|
.shadow(color: .black, radius: 2, x: 0, y: 1)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Right arrow (pointing left)
|
||||||
|
Image(systemName: "arrowtriangle.left.fill")
|
||||||
|
.font(.system(size: 20))
|
||||||
|
.foregroundColor(.white.opacity(0.7))
|
||||||
|
.shadow(color: .black, radius: 2, x: 0, y: 1)
|
||||||
|
}
|
||||||
|
// Position arrows just outside where script text starts/ends
|
||||||
|
.padding(.leading, geometry.size.width > geometry.size.height ? 110 : 8)
|
||||||
|
.padding(.trailing, geometry.size.width > geometry.size.height ? 110 : 12)
|
||||||
|
.position(x: geometry.size.width / 2, y: geometry.size.height * 0.25)
|
||||||
|
|
||||||
|
// Controls overlay
|
||||||
|
if showControls {
|
||||||
|
VStack {
|
||||||
|
// Top bar - add extra left padding in landscape for Dynamic Island
|
||||||
|
HStack {
|
||||||
|
Button(action: {
|
||||||
|
stopTimers()
|
||||||
|
isRecording = false // Stop recording if exiting
|
||||||
|
isPresented = false
|
||||||
|
}) {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.font(.system(size: 32))
|
||||||
|
.foregroundStyle(.white.opacity(0.9))
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if !isRecording {
|
||||||
|
Button(action: {
|
||||||
|
stopTimers()
|
||||||
|
isPresented = false
|
||||||
|
}) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "pencil")
|
||||||
|
Text("Edit")
|
||||||
|
}
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(.ultraThinMaterial)
|
||||||
|
.cornerRadius(20)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Dynamic padding: extra on left in landscape to clear Dynamic Island
|
||||||
|
.padding(.leading, geometry.size.width > geometry.size.height ? 70 : 20)
|
||||||
|
.padding(.trailing, 20)
|
||||||
|
.padding(.top, 60)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Bottom controls
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
// Play/Pause, Reset, Speed indicator, Record
|
||||||
|
HStack(spacing: 24) {
|
||||||
|
// Reset button
|
||||||
|
Button(action: {
|
||||||
|
scrollOffset = 0
|
||||||
|
resetControlsTimer()
|
||||||
|
}) {
|
||||||
|
Image(systemName: "arrow.counterclockwise")
|
||||||
|
.font(.system(size: 24))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.frame(width: 56, height: 56)
|
||||||
|
.background(.ultraThinMaterial)
|
||||||
|
.cornerRadius(28)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Play/Pause button
|
||||||
|
Button(action: {
|
||||||
|
isPlaying.toggle()
|
||||||
|
if isPlaying {
|
||||||
|
startScrolling()
|
||||||
|
} else {
|
||||||
|
timer?.invalidate()
|
||||||
|
timer = nil
|
||||||
|
}
|
||||||
|
resetControlsTimer()
|
||||||
|
}) {
|
||||||
|
Image(systemName: isPlaying ? "pause.fill" : "play.fill")
|
||||||
|
.font(.system(size: 32))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.frame(width: 72, height: 72)
|
||||||
|
.background(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [.blue, .purple],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.cornerRadius(36)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Speed indicator
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
Text("\(Int(scrollSpeed))")
|
||||||
|
.font(.system(size: 20, weight: .bold))
|
||||||
|
Text("Speed")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
}
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.frame(width: 56, height: 56)
|
||||||
|
.background(.ultraThinMaterial)
|
||||||
|
.cornerRadius(28)
|
||||||
|
|
||||||
|
// Voice Trigger + Record buttons stacked vertically
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
// Voice Trigger Button - hidden during recording
|
||||||
|
if !isRecording {
|
||||||
|
Button(action: {
|
||||||
|
if isListeningForVoice {
|
||||||
|
// Cancel voice trigger
|
||||||
|
voiceTriggerManager.stopListening()
|
||||||
|
isListeningForVoice = false
|
||||||
|
voicePulseAnimation = false
|
||||||
|
} else {
|
||||||
|
// Start listening for voice trigger
|
||||||
|
isListeningForVoice = true
|
||||||
|
voicePulseAnimation = true
|
||||||
|
voiceTriggerManager.startListening { [self] in
|
||||||
|
// Triggered! Start recording
|
||||||
|
isListeningForVoice = false
|
||||||
|
voicePulseAnimation = false
|
||||||
|
|
||||||
|
// Pause scrolling if active (will resume after countdown)
|
||||||
|
if isPlaying {
|
||||||
|
timer?.invalidate()
|
||||||
|
timer = nil
|
||||||
|
}
|
||||||
|
isRecording = true
|
||||||
|
showControls = false
|
||||||
|
startCountdown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resetControlsTimer()
|
||||||
|
}) {
|
||||||
|
ZStack {
|
||||||
|
// Pulsing background when listening
|
||||||
|
if isListeningForVoice {
|
||||||
|
Circle()
|
||||||
|
.fill(.red.opacity(0.3))
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
.scaleEffect(voicePulseAnimation ? 1.4 : 1.0)
|
||||||
|
.opacity(voicePulseAnimation ? 0.3 : 0.8)
|
||||||
|
.animation(
|
||||||
|
.easeInOut(duration: 0.8)
|
||||||
|
.repeatForever(autoreverses: true),
|
||||||
|
value: voicePulseAnimation
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Circle()
|
||||||
|
.fill(.red)
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
|
||||||
|
if isListeningForVoice {
|
||||||
|
Image(systemName: "waveform")
|
||||||
|
.font(.system(size: 18, weight: .bold))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "mic.fill")
|
||||||
|
.font(.system(size: 18))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record Button
|
||||||
|
Button(action: {
|
||||||
|
// Cancel voice trigger if active
|
||||||
|
if isListeningForVoice {
|
||||||
|
voiceTriggerManager.stopListening()
|
||||||
|
isListeningForVoice = false
|
||||||
|
voicePulseAnimation = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if isRecording {
|
||||||
|
// STOP
|
||||||
|
isRecording = false
|
||||||
|
isPlaying = false
|
||||||
|
timer?.invalidate()
|
||||||
|
timer = nil
|
||||||
|
countdownValue = nil // Safety
|
||||||
|
} else {
|
||||||
|
// START RECORDING
|
||||||
|
// Pause scrolling if active (will resume after countdown)
|
||||||
|
if isPlaying {
|
||||||
|
timer?.invalidate()
|
||||||
|
timer = nil
|
||||||
|
}
|
||||||
|
isRecording = true
|
||||||
|
showControls = false // Hide UI
|
||||||
|
startCountdown()
|
||||||
|
}
|
||||||
|
resetControlsTimer()
|
||||||
|
}) {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(.white)
|
||||||
|
.frame(width: 56, height: 56)
|
||||||
|
|
||||||
|
if isRecording {
|
||||||
|
RoundedRectangle(cornerRadius: 4)
|
||||||
|
.fill(.red)
|
||||||
|
.frame(width: 24, height: 24)
|
||||||
|
} else {
|
||||||
|
Circle()
|
||||||
|
.fill(.red)
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Speed slider
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "tortoise.fill")
|
||||||
|
.foregroundColor(.white.opacity(0.7))
|
||||||
|
|
||||||
|
Slider(value: $scrollSpeed, in: 10...85, step: 5)
|
||||||
|
.tint(.white)
|
||||||
|
.onChange(of: scrollSpeed) { _, _ in
|
||||||
|
if isPlaying {
|
||||||
|
startScrolling()
|
||||||
|
}
|
||||||
|
resetControlsTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
Image(systemName: "hare.fill")
|
||||||
|
.foregroundColor(.white.opacity(0.7))
|
||||||
|
}
|
||||||
|
// Dynamic padding for Dynamic Island clearance in landscape
|
||||||
|
.padding(.leading, geometry.size.width > geometry.size.height ? 70 : 20)
|
||||||
|
.padding(.trailing, 20)
|
||||||
|
|
||||||
|
// Font size controls - right justified
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Text("Font Size")
|
||||||
|
.foregroundColor(.white.opacity(0.7))
|
||||||
|
.padding(.trailing, 8) // Extra space before button
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
if fontSize > 18 {
|
||||||
|
fontSize -= 4
|
||||||
|
}
|
||||||
|
resetControlsTimer()
|
||||||
|
}) {
|
||||||
|
Image(systemName: "textformat.size.smaller")
|
||||||
|
.font(.system(size: 20))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
.background(.ultraThinMaterial)
|
||||||
|
.cornerRadius(10)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("\(Int(fontSize))")
|
||||||
|
.font(.system(size: 18, weight: .medium))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.frame(width: 40)
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
if fontSize < 72 {
|
||||||
|
fontSize += 4
|
||||||
|
}
|
||||||
|
resetControlsTimer()
|
||||||
|
}) {
|
||||||
|
Image(systemName: "textformat.size.larger")
|
||||||
|
.font(.system(size: 20))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
.background(.ultraThinMaterial)
|
||||||
|
.cornerRadius(10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.trailing, 20)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 20)
|
||||||
|
.background(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [.clear, .black.opacity(0.6), .black.opacity(0.8)],
|
||||||
|
startPoint: .top,
|
||||||
|
endPoint: .bottom
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.transition(.opacity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onTapGesture {
|
||||||
|
withAnimation(.easeInOut(duration: 0.3)) {
|
||||||
|
showControls.toggle()
|
||||||
|
}
|
||||||
|
if showControls {
|
||||||
|
resetControlsTimer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startScrolling() {
|
||||||
|
timer?.invalidate()
|
||||||
|
let startTime = Date()
|
||||||
|
|
||||||
|
timer = Timer.scheduledTimer(withTimeInterval: 1.0 / 60.0, repeats: true) { _ in
|
||||||
|
Task { @MainActor in
|
||||||
|
// Check for free tier limit (2 minutes = 120 seconds)
|
||||||
|
if !subscriptionManager.isSubscribed && isRecording {
|
||||||
|
let elapsedTime = Date().timeIntervalSince(startTime)
|
||||||
|
if elapsedTime >= 120 {
|
||||||
|
// Stop everything
|
||||||
|
stopRecordingAndScrolling()
|
||||||
|
showLimitAlert = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let increment = scrollSpeed / 60.0
|
||||||
|
// Scroll from bottom (offset 0) until text has scrolled completely off the top
|
||||||
|
let maxScroll = containerHeight + textHeight
|
||||||
|
|
||||||
|
if scrollOffset < maxScroll {
|
||||||
|
scrollOffset += increment
|
||||||
|
} else {
|
||||||
|
// Reached the end
|
||||||
|
isPlaying = false
|
||||||
|
timer?.invalidate()
|
||||||
|
timer = nil
|
||||||
|
|
||||||
|
// If we were recording and reached the end, stop recording too?
|
||||||
|
// User didn't specify, but usually teleprompter apps stop recording when script ends or user manually stops.
|
||||||
|
// Keeping existing behavior: script stops scrolling, but recording continues until user stops.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopRecordingAndScrolling() {
|
||||||
|
isRecording = false
|
||||||
|
isPlaying = false
|
||||||
|
timer?.invalidate()
|
||||||
|
timer = nil
|
||||||
|
countdownValue = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startCountdown() {
|
||||||
|
countdownValue = 3
|
||||||
|
|
||||||
|
Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { timer in
|
||||||
|
if let count = countdownValue {
|
||||||
|
if count > 1 {
|
||||||
|
countdownValue = count - 1
|
||||||
|
} else {
|
||||||
|
// Countdown finished
|
||||||
|
countdownValue = nil
|
||||||
|
timer.invalidate()
|
||||||
|
|
||||||
|
// Start scrolling
|
||||||
|
isPlaying = true
|
||||||
|
startScrolling()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
timer.invalidate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resetControlsTimer() {
|
||||||
|
controlsTimer?.invalidate()
|
||||||
|
|
||||||
|
// Don't auto-hide if not recording
|
||||||
|
// Actually user said "controls will disappear" on record.
|
||||||
|
// We auto-hide normally after 4s.
|
||||||
|
controlsTimer = Timer.scheduledTimer(withTimeInterval: 4.0, repeats: false) { _ in
|
||||||
|
withAnimation(.easeInOut(duration: 0.3)) {
|
||||||
|
showControls = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopTimers() {
|
||||||
|
timer?.invalidate()
|
||||||
|
timer = nil
|
||||||
|
controlsTimer?.invalidate()
|
||||||
|
controlsTimer = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
TeleprompterView(isPresented: .constant(true), script: """
|
||||||
|
Welcome to CheapTeleprompter!
|
||||||
|
""")
|
||||||
|
}
|
||||||
42
CheapTeleprompter/TransparentBackgroundView.swift
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
struct TransparencyEnforcer: UIViewRepresentable {
|
||||||
|
func makeUIView(context: Context) -> UIView {
|
||||||
|
let view = UIView()
|
||||||
|
view.backgroundColor = .clear
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIView(_ uiView: UIView, context: Context) {
|
||||||
|
// Optimization: Check if cleanup is actually needed before dispatching to main thread.
|
||||||
|
// This prevents flooding the main loop during animation frames (rotation), which can cause Signal 9 crashes.
|
||||||
|
let needsCleanup = uiView.superview?.backgroundColor != .clear || uiView.window?.backgroundColor != .clear
|
||||||
|
|
||||||
|
if needsCleanup {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
// Traverse up the view hierarchy
|
||||||
|
var current: UIView? = uiView
|
||||||
|
while let view = current {
|
||||||
|
if view.backgroundColor != .clear {
|
||||||
|
view.backgroundColor = .clear
|
||||||
|
view.isOpaque = false
|
||||||
|
}
|
||||||
|
current = view.superview
|
||||||
|
}
|
||||||
|
|
||||||
|
// Window cleanup
|
||||||
|
if let window = uiView.window {
|
||||||
|
if window.backgroundColor != .clear {
|
||||||
|
window.backgroundColor = .clear
|
||||||
|
window.isOpaque = false
|
||||||
|
}
|
||||||
|
if let rootParams = window.rootViewController?.view, rootParams.backgroundColor != .clear {
|
||||||
|
rootParams.backgroundColor = .clear
|
||||||
|
rootParams.isOpaque = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
56
CheapTeleprompter/VideoPlayerView.swift
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
//
|
||||||
|
// VideoPlayerView.swift
|
||||||
|
// CheapTeleprompter
|
||||||
|
//
|
||||||
|
// Created by Jared Evans on 1/9/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import AVKit
|
||||||
|
|
||||||
|
struct VideoPlayerView: View {
|
||||||
|
let videoURL: URL
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
VideoPlayer(player: AVPlayer(url: videoURL))
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
VStack {
|
||||||
|
HStack {
|
||||||
|
Button(action: {
|
||||||
|
dismiss()
|
||||||
|
}) {
|
||||||
|
Text("Redo")
|
||||||
|
.font(.system(size: 18, weight: .semibold))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(.ultraThinMaterial)
|
||||||
|
.cornerRadius(20)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
ShareLink(item: videoURL) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text("Save")
|
||||||
|
Image(systemName: "square.and.arrow.up")
|
||||||
|
}
|
||||||
|
.font(.system(size: 18, weight: .semibold))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(.ultraThinMaterial)
|
||||||
|
.cornerRadius(20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.top, 10) // Near top but respecting Safe Area (VStack is inside safe area)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
102
CheapTeleprompter/VoiceTriggerManager.swift
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
//
|
||||||
|
// VoiceTriggerManager.swift
|
||||||
|
// CheapTeleprompter
|
||||||
|
//
|
||||||
|
// Created by Claude on 1/16/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import AVFoundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
/// Monitors microphone input for loud sounds to trigger recording
|
||||||
|
@MainActor
|
||||||
|
class VoiceTriggerManager: ObservableObject {
|
||||||
|
@Published var isListening = false
|
||||||
|
@Published var currentLevel: Float = 0 // For visual feedback (0-1 range)
|
||||||
|
|
||||||
|
private var audioEngine: AVAudioEngine?
|
||||||
|
private var onTrigger: (() -> Void)?
|
||||||
|
|
||||||
|
// Threshold for triggering (in linear scale, 0-1)
|
||||||
|
// ~0.1 corresponds to a moderately loud sound like a clap or loud voice
|
||||||
|
private let triggerThreshold: Float = 0.15
|
||||||
|
|
||||||
|
/// Start listening for loud sounds
|
||||||
|
/// - Parameter onTrigger: Callback when a loud sound is detected
|
||||||
|
func startListening(onTrigger: @escaping () -> Void) {
|
||||||
|
self.onTrigger = onTrigger
|
||||||
|
|
||||||
|
// Configure audio session for recording
|
||||||
|
let audioSession = AVAudioSession.sharedInstance()
|
||||||
|
do {
|
||||||
|
try audioSession.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker, .allowBluetoothA2DP])
|
||||||
|
try audioSession.setActive(true)
|
||||||
|
} catch {
|
||||||
|
print("[VoiceTrigger] Failed to configure audio session: \(error)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
audioEngine = AVAudioEngine()
|
||||||
|
guard let audioEngine = audioEngine else { return }
|
||||||
|
|
||||||
|
let inputNode = audioEngine.inputNode
|
||||||
|
let format = inputNode.outputFormat(forBus: 0)
|
||||||
|
|
||||||
|
// Install tap to monitor audio levels
|
||||||
|
inputNode.installTap(onBus: 0, bufferSize: 1024, format: format) { [weak self] buffer, _ in
|
||||||
|
guard let self = self else { return }
|
||||||
|
|
||||||
|
// Calculate RMS (root mean square) of the audio buffer
|
||||||
|
guard let channelData = buffer.floatChannelData?[0] else { return }
|
||||||
|
let frameLength = Int(buffer.frameLength)
|
||||||
|
|
||||||
|
var sum: Float = 0
|
||||||
|
for i in 0..<frameLength {
|
||||||
|
sum += channelData[i] * channelData[i]
|
||||||
|
}
|
||||||
|
let rms = sqrt(sum / Float(frameLength))
|
||||||
|
|
||||||
|
// Update on main thread
|
||||||
|
Task { @MainActor in
|
||||||
|
self.currentLevel = min(rms * 5, 1.0) // Scale for visual display
|
||||||
|
|
||||||
|
// Check if threshold exceeded
|
||||||
|
if rms > self.triggerThreshold && self.isListening {
|
||||||
|
self.trigger()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try audioEngine.start()
|
||||||
|
isListening = true
|
||||||
|
print("[VoiceTrigger] Started listening for loud sounds")
|
||||||
|
} catch {
|
||||||
|
print("[VoiceTrigger] Failed to start audio engine: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop listening and clean up
|
||||||
|
func stopListening() {
|
||||||
|
audioEngine?.inputNode.removeTap(onBus: 0)
|
||||||
|
audioEngine?.stop()
|
||||||
|
audioEngine = nil
|
||||||
|
isListening = false
|
||||||
|
currentLevel = 0
|
||||||
|
onTrigger = nil
|
||||||
|
print("[VoiceTrigger] Stopped listening")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func trigger() {
|
||||||
|
print("[VoiceTrigger] Loud sound detected! Triggering...")
|
||||||
|
let callback = onTrigger
|
||||||
|
stopListening()
|
||||||
|
callback?()
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
// Clean up if still listening
|
||||||
|
audioEngine?.inputNode.removeTap(onBus: 0)
|
||||||
|
audioEngine?.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
47
README.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# CheapTeleprompter 🎬
|
||||||
|
|
||||||
|
A simple, effective, and free teleprompter app for iOS that lets you record yourself while reading a scrolling script.
|
||||||
|
|
||||||
|
## Features ✨
|
||||||
|
|
||||||
|
* **Teleprompter & Camera**: Read your script while looking directly at the camera (selfie mode).
|
||||||
|
* **Video Recording**: Record high-quality video with audio.
|
||||||
|
* **3-2-1 Countdown**: Get ready with a 3-second countdown before recording starts and the script begins scrolling.
|
||||||
|
* **Customizable**:
|
||||||
|
* Adjust **Scroll Speed** with a slider (Tortoise 🐢 to Hare 🐇).
|
||||||
|
* Adjust **Font Size** with +/- buttons.
|
||||||
|
* **Smart Controls**: UI controls automatically hide while recording to give you a clear view.
|
||||||
|
* **Video Playback**: Watch your recording immediately in full-screen.
|
||||||
|
* **Share & Save**: Share videos to other apps or save them to your Photos library via the share sheet.
|
||||||
|
|
||||||
|
## How to Use 📱
|
||||||
|
|
||||||
|
1. **Edit Script**: Open the app and paste or type your script.
|
||||||
|
2. **Show Teleprompter**: Tap the button to enter the recording view.
|
||||||
|
3. **Adjust Settings**: Use the speed slider and font size buttons to customize your reading experience.
|
||||||
|
4. **Record**:
|
||||||
|
* Tap the **Red Record Button**.
|
||||||
|
* Wait for the **3-2-1 Countdown**.
|
||||||
|
* Read the script as it scrolls.
|
||||||
|
5. **Stop**: Tap the screen to reveal controls, then tap the Stop button.
|
||||||
|
6. **Review**: Watch your video. Tap **Save** to share or save it to Photos.
|
||||||
|
|
||||||
|
## Requirements 🛠️
|
||||||
|
|
||||||
|
* **iOS 18.6+**
|
||||||
|
* **Permissions**:
|
||||||
|
* Camera (for selfie view)
|
||||||
|
* Microphone (for audio recording)
|
||||||
|
* Photo Library (to save videos)
|
||||||
|
|
||||||
|
## Building the Project 🏗️
|
||||||
|
|
||||||
|
This project is built with **SwiftUI** and **AVFoundation**.
|
||||||
|
|
||||||
|
1. Open `CheapTeleprompter.xcodeproj` in Xcode.
|
||||||
|
2. Ensure you have a valid signing team selected.
|
||||||
|
3. Build and run on a physical device (Camera features may usually not work fully on Simulator).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Built with ❤️ by Antigravity*
|
||||||
85
Subscriptions.storekit
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
{
|
||||||
|
"appPolicies" : {
|
||||||
|
"eula" : "",
|
||||||
|
"policies" : [
|
||||||
|
{
|
||||||
|
"locale" : "en_US",
|
||||||
|
"policyText" : "",
|
||||||
|
"policyURL" : ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"identifier" : "CCAD5BB7",
|
||||||
|
"nonRenewingSubscriptions" : [
|
||||||
|
|
||||||
|
],
|
||||||
|
"products" : [
|
||||||
|
|
||||||
|
],
|
||||||
|
"settings" : {
|
||||||
|
"_askToBuyEnabled" : false,
|
||||||
|
"_billingGracePeriodEnabled" : false,
|
||||||
|
"_billingIssuesEnabled" : false,
|
||||||
|
"_disableDialogs" : false,
|
||||||
|
"_failTransactionsEnabled" : false,
|
||||||
|
"_locale" : "en_US",
|
||||||
|
"_renewalBillingIssuesEnabled" : false,
|
||||||
|
"_storefront" : "USA",
|
||||||
|
"_storeKitErrors" : [
|
||||||
|
|
||||||
|
],
|
||||||
|
"_timeRate" : 0
|
||||||
|
},
|
||||||
|
"subscriptionGroups" : [
|
||||||
|
{
|
||||||
|
"id" : "1270897E",
|
||||||
|
"localizations" : [
|
||||||
|
{
|
||||||
|
"description" : "",
|
||||||
|
"displayName" : "Unlock Unlimited Time",
|
||||||
|
"locale" : "en_US"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"name" : "unlock_unlimited_time",
|
||||||
|
"subscriptions" : [
|
||||||
|
{
|
||||||
|
"adHocOffers" : [
|
||||||
|
|
||||||
|
],
|
||||||
|
"codeOffers" : [
|
||||||
|
|
||||||
|
],
|
||||||
|
"displayPrice" : "1.99",
|
||||||
|
"familyShareable" : false,
|
||||||
|
"groupNumber" : 1,
|
||||||
|
"internalID" : "84D9A3B6",
|
||||||
|
"introductoryOffer" : {
|
||||||
|
"displayPrice" : "0.99",
|
||||||
|
"internalID" : "321873B3",
|
||||||
|
"paymentMode" : "free",
|
||||||
|
"subscriptionPeriod" : "P1M"
|
||||||
|
},
|
||||||
|
"localizations" : [
|
||||||
|
{
|
||||||
|
"description" : "",
|
||||||
|
"displayName" : "",
|
||||||
|
"locale" : "en_US"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"productID" : "unlock_unlimited_time_teleprompting",
|
||||||
|
"recurringSubscriptionPeriod" : "P1Y",
|
||||||
|
"referenceName" : "Unlock Unlimited Time",
|
||||||
|
"subscriptionGroupID" : "1270897E",
|
||||||
|
"type" : "RecurringSubscription",
|
||||||
|
"winbackOffers" : [
|
||||||
|
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"version" : {
|
||||||
|
"major" : 4,
|
||||||
|
"minor" : 0
|
||||||
|
}
|
||||||
|
}
|
||||||
27
check_build.sh
Executable file
@@ -0,0 +1,27 @@
|
|||||||
|
#!/bin/zsh
|
||||||
|
set -o pipefail # Fail if xcodebuild fails, even with xcbeautify
|
||||||
|
|
||||||
|
# --- Configuration ---
|
||||||
|
SCHEME="CheapTeleprompter"
|
||||||
|
DEVICE_NAME="iPhone 17 Pro"
|
||||||
|
BUILD_PATH="./build"
|
||||||
|
|
||||||
|
echo "🔍 Checking compilation for $SCHEME..."
|
||||||
|
|
||||||
|
# Build Only (No Install/Launch)
|
||||||
|
# We use 'env -u' to hide Homebrew variables
|
||||||
|
# We use '-derivedDataPath' to keep it isolated
|
||||||
|
env -u CC -u CXX -u LIBCLANG_PATH xcodebuild \
|
||||||
|
-scheme "$SCHEME" \
|
||||||
|
-destination "platform=iOS Simulator,name=$DEVICE_NAME" \
|
||||||
|
-configuration Debug \
|
||||||
|
-derivedDataPath "$BUILD_PATH" \
|
||||||
|
build | xcbeautify
|
||||||
|
|
||||||
|
# Check exit code of the pipeline
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo "✅ Build Succeeded. No errors found."
|
||||||
|
else
|
||||||
|
echo "❌ Build Failed."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||