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