commit 264c7b94ccd5cd8425d64391d9375c4f13bfa25e Author: jared Date: Mon Jan 19 22:12:36 2026 -0500 Initial commit Add CheapTeleprompter iOS app with teleprompter functionality, camera integration, voice trigger support, and subscription management. Co-Authored-By: Claude Opus 4.5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eeec3af --- /dev/null +++ b/.gitignore @@ -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 +*~ diff --git a/CheapTeleprompter.xcodeproj/project.pbxproj b/CheapTeleprompter.xcodeproj/project.pbxproj new file mode 100644 index 0000000..70dd7e7 --- /dev/null +++ b/CheapTeleprompter.xcodeproj/project.pbxproj @@ -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 = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 7F3DA60D2F117B11003E4214 /* CheapTeleprompter */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = CheapTeleprompter; + sourceTree = ""; + }; +/* 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 = ""; + }; + 7F3DA60C2F117B11003E4214 /* Products */ = { + isa = PBXGroup; + children = ( + 7F3DA60B2F117B11003E4214 /* CheapTeleprompter.app */, + ); + name = Products; + sourceTree = ""; + }; +/* 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 */; +} diff --git a/CheapTeleprompter.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/CheapTeleprompter.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/CheapTeleprompter.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/CheapTeleprompter.xcodeproj/xcshareddata/xcschemes/CheapTeleprompter.xcscheme b/CheapTeleprompter.xcodeproj/xcshareddata/xcschemes/CheapTeleprompter.xcscheme new file mode 100644 index 0000000..7464635 --- /dev/null +++ b/CheapTeleprompter.xcodeproj/xcshareddata/xcschemes/CheapTeleprompter.xcscheme @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/CheapTeleprompter/Assets.xcassets/AccentColor.colorset/Contents.json b/CheapTeleprompter/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/CheapTeleprompter/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/100.png b/CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/100.png new file mode 100644 index 0000000..279d309 Binary files /dev/null and b/CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/100.png differ diff --git a/CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/1024.png b/CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/1024.png new file mode 100644 index 0000000..c2779e2 Binary files /dev/null and b/CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/1024.png differ diff --git a/CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/114.png b/CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/114.png new file mode 100644 index 0000000..9a64f8a Binary files /dev/null and b/CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/114.png differ diff --git a/CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/120.png b/CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/120.png new file mode 100644 index 0000000..8408000 Binary files /dev/null and b/CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/120.png differ diff --git a/CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/144.png b/CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/144.png new file mode 100644 index 0000000..b4f6e7b Binary files /dev/null and b/CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/144.png differ diff --git a/CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/152.png b/CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/152.png new file mode 100644 index 0000000..53bcf29 Binary files /dev/null and b/CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/152.png differ diff --git a/CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/167.png b/CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/167.png new file mode 100644 index 0000000..3cd3684 Binary files /dev/null and b/CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/167.png differ diff --git a/CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/180.png b/CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/180.png new file mode 100644 index 0000000..5a731f6 Binary files /dev/null and b/CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/180.png differ diff --git a/CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/20.png b/CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/20.png new file mode 100644 index 0000000..4f32d8f Binary files /dev/null and b/CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/20.png differ diff --git a/CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/29.png b/CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/29.png new file mode 100644 index 0000000..87a91d5 Binary files /dev/null and b/CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/29.png differ diff --git a/CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/40.png b/CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/40.png new file mode 100644 index 0000000..6828df0 Binary files /dev/null and b/CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/40.png differ diff --git a/CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/50.png b/CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/50.png new file mode 100644 index 0000000..e8746eb Binary files /dev/null and b/CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/50.png differ diff --git a/CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/57.png b/CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/57.png new file mode 100644 index 0000000..b26b317 Binary files /dev/null and b/CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/57.png differ diff --git a/CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/58.png b/CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/58.png new file mode 100644 index 0000000..58aa393 Binary files /dev/null and b/CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/58.png differ diff --git a/CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/60.png b/CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/60.png new file mode 100644 index 0000000..0d1c28b Binary files /dev/null and b/CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/60.png differ diff --git a/CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/72.png b/CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/72.png new file mode 100644 index 0000000..ad804fe Binary files /dev/null and b/CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/72.png differ diff --git a/CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/76.png b/CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/76.png new file mode 100644 index 0000000..59a1e33 Binary files /dev/null and b/CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/76.png differ diff --git a/CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/80.png b/CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/80.png new file mode 100644 index 0000000..047039e Binary files /dev/null and b/CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/80.png differ diff --git a/CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/87.png b/CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/87.png new file mode 100644 index 0000000..71b5725 Binary files /dev/null and b/CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/87.png differ diff --git a/CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/Contents.json b/CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..65b74d7 --- /dev/null +++ b/CheapTeleprompter/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -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"}]} \ No newline at end of file diff --git a/CheapTeleprompter/Assets.xcassets/Contents.json b/CheapTeleprompter/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/CheapTeleprompter/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CheapTeleprompter/Assets.xcassets/SubscriptionIcon.imageset/Contents.json b/CheapTeleprompter/Assets.xcassets/SubscriptionIcon.imageset/Contents.json new file mode 100644 index 0000000..23dca76 --- /dev/null +++ b/CheapTeleprompter/Assets.xcassets/SubscriptionIcon.imageset/Contents.json @@ -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 + } +} diff --git a/CheapTeleprompter/Assets.xcassets/SubscriptionIcon.imageset/subscription_icon.png b/CheapTeleprompter/Assets.xcassets/SubscriptionIcon.imageset/subscription_icon.png new file mode 100644 index 0000000..accbfff Binary files /dev/null and b/CheapTeleprompter/Assets.xcassets/SubscriptionIcon.imageset/subscription_icon.png differ diff --git a/CheapTeleprompter/CameraManager.swift b/CheapTeleprompter/CameraManager.swift new file mode 100644 index 0000000..f2aab6d --- /dev/null +++ b/CheapTeleprompter/CameraManager.swift @@ -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) + } + } +} diff --git a/CheapTeleprompter/CameraPreviewView.swift b/CheapTeleprompter/CameraPreviewView.swift new file mode 100644 index 0000000..d3cef77 --- /dev/null +++ b/CheapTeleprompter/CameraPreviewView.swift @@ -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() +} diff --git a/CheapTeleprompter/CameraWindowManager.swift b/CheapTeleprompter/CameraWindowManager.swift new file mode 100644 index 0000000..ce1a7ee --- /dev/null +++ b/CheapTeleprompter/CameraWindowManager.swift @@ -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? + private var storedOnRecordingFinished: ((URL) -> Void)? + + private init() {} + + func show(isRecording: Binding, 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: UIHostingController { + override var supportedInterfaceOrientations: UIInterfaceOrientationMask { + return .portrait + } + + override var shouldAutorotate: Bool { + return false + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + } +} diff --git a/CheapTeleprompter/CheapTeleprompterApp.swift b/CheapTeleprompter/CheapTeleprompterApp.swift new file mode 100644 index 0000000..2d6e6e2 --- /dev/null +++ b/CheapTeleprompter/CheapTeleprompterApp.swift @@ -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) + } + } +} diff --git a/CheapTeleprompter/ContentView.swift b/CheapTeleprompter/ContentView.swift new file mode 100644 index 0000000..3625e3e --- /dev/null +++ b/CheapTeleprompter/ContentView.swift @@ -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() +} diff --git a/CheapTeleprompter/RotationDetector.swift b/CheapTeleprompter/RotationDetector.swift new file mode 100644 index 0000000..7aacab7 --- /dev/null +++ b/CheapTeleprompter/RotationDetector.swift @@ -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) + } + } + } +} diff --git a/CheapTeleprompter/ScriptEditorView.swift b/CheapTeleprompter/ScriptEditorView.swift new file mode 100644 index 0000000..90e7e9c --- /dev/null +++ b/CheapTeleprompter/ScriptEditorView.swift @@ -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() +} diff --git a/CheapTeleprompter/SubscriptionManager.swift b/CheapTeleprompter/SubscriptionManager.swift new file mode 100644 index 0000000..1c34901 --- /dev/null +++ b/CheapTeleprompter/SubscriptionManager.swift @@ -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 = [] + + // 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() + + // 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(_ result: VerificationResult) 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 +} diff --git a/CheapTeleprompter/SubscriptionSheet.swift b/CheapTeleprompter/SubscriptionSheet.swift new file mode 100644 index 0000000..92cd208 --- /dev/null +++ b/CheapTeleprompter/SubscriptionSheet.swift @@ -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]) + } +} diff --git a/CheapTeleprompter/TeleprompterView.swift b/CheapTeleprompter/TeleprompterView.swift new file mode 100644 index 0000000..693f634 --- /dev/null +++ b/CheapTeleprompter/TeleprompterView.swift @@ -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: UIViewControllerRepresentable { + let content: Content + let orientation: UIInterfaceOrientationMask + + init(orientation: UIInterfaceOrientationMask, @ViewBuilder content: () -> Content) { + self.orientation = orientation + self.content = content() + } + + func makeUIViewController(context: Context) -> OrientationLockedViewController { + OrientationLockedViewController(rootView: content, orientation: orientation) + } + + func updateUIViewController(_ uiViewController: OrientationLockedViewController, context: Context) { + } +} + +class OrientationLockedViewController: UIHostingController { + 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! + """) +} diff --git a/CheapTeleprompter/TransparentBackgroundView.swift b/CheapTeleprompter/TransparentBackgroundView.swift new file mode 100644 index 0000000..9e65cb9 --- /dev/null +++ b/CheapTeleprompter/TransparentBackgroundView.swift @@ -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 + } + } + } + } + } +} diff --git a/CheapTeleprompter/VideoPlayerView.swift b/CheapTeleprompter/VideoPlayerView.swift new file mode 100644 index 0000000..f38625f --- /dev/null +++ b/CheapTeleprompter/VideoPlayerView.swift @@ -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() + } + } + } +} diff --git a/CheapTeleprompter/VoiceTriggerManager.swift b/CheapTeleprompter/VoiceTriggerManager.swift new file mode 100644 index 0000000..2f8ab6e --- /dev/null +++ b/CheapTeleprompter/VoiceTriggerManager.swift @@ -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.. 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() + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..f3a3512 --- /dev/null +++ b/README.md @@ -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* diff --git a/Subscriptions.storekit b/Subscriptions.storekit new file mode 100644 index 0000000..3658d61 --- /dev/null +++ b/Subscriptions.storekit @@ -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 + } +} diff --git a/check_build.sh b/check_build.sh new file mode 100755 index 0000000..db2c369 --- /dev/null +++ b/check_build.sh @@ -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