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>
This commit is contained in:
2026-01-19 22:12:36 -05:00
commit 264c7b94cc
44 changed files with 2654 additions and 0 deletions

63
.gitignore vendored Normal file
View 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
*~

View 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 */;
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -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>

View File

@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

View File

@@ -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"}]}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 500 KiB

View 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)
}
}
}

View 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()
}

View 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()
}
}

View 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)
}
}
}

View 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()
}

View 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)
}
}
}
}

View 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()
}

View 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
}

View 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])
}
}

View 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!
""")
}

View 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
}
}
}
}
}
}

View 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()
}
}
}
}

View 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
View 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
View 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
View 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