diff --git a/apps/ios/.gitignore b/apps/ios/.gitignore new file mode 100644 index 0000000..5465448 --- /dev/null +++ b/apps/ios/.gitignore @@ -0,0 +1,5 @@ +# Large binary — install via scripts/fetch-vlckit.sh + link-vlckit.sh on Mac +Vendor/ +*.xcworkspace +Pods/ +Podfile.lock diff --git a/apps/ios/Podfile b/apps/ios/Podfile new file mode 100644 index 0000000..76da8d0 --- /dev/null +++ b/apps/ios/Podfile @@ -0,0 +1,15 @@ +platform :ios, '17.0' +use_frameworks! + +target 'VMC' do + pod 'MobileVLCKit', '~> 3.6.0' +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '17.0' + config.build_settings['BUILD_LIBRARY_FOR_DISTRIBUTION'] = 'YES' + end + end +end diff --git a/apps/ios/README.md b/apps/ios/README.md new file mode 100644 index 0000000..f6ec68b --- /dev/null +++ b/apps/ios/README.md @@ -0,0 +1,34 @@ +# VMC iOS (SwiftUI + MobileVLCKit) + +Native iPhone/iPad app voor Vonas Media Cluster. + +## Op de Mac + +```bash +cd ~/Code/mediacluster/apps/ios +open VMC.xcodeproj +``` + +Eerste keer (of na schone sync) VLCKit zetten: + +```bash +bash scripts/fetch-vlckit.sh # download ~250MB (eenmalig) +bash scripts/link-vlckit.sh # kopieert naar Vendor/ + linkt in Xcode +``` + +In Xcode: +1. **Signing & Capabilities** → jouw Team +2. iPhone selecteren → ▶ Run (versie **0.2.0**) + +## Wat is nieuw (0.2.0) + +- **VLC-speler** i.p.v. AVPlayer → MKV/MP4/TS/AVI/HLS e.d. +- HTTP/lokale streams toegestaan (ATS) +- Layout overflow op detailschermen gefixt +- Vaste **footer-menu**: Home / Live TV / Zoeken / Account +- Live TV + OpenSubtitles in de speler +- Premium dark UI + +## Let op + +`Vendor/MobileVLCKit.xcframework` staat niet in git (te groot). Altijd op de Mac via de scripts installeren. diff --git a/apps/ios/VMC.xcodeproj/project.pbxproj b/apps/ios/VMC.xcodeproj/project.pbxproj new file mode 100644 index 0000000..0f107d6 --- /dev/null +++ b/apps/ios/VMC.xcodeproj/project.pbxproj @@ -0,0 +1,396 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = {}; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + 6BE9B837C256472081A772BB /* VMCApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC6F977475F647EAB526B851 /* VMCApp.swift */; }; + 99D51124A40D4FB6BB32E20E /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2EA13EA1EAB446784C29601 /* Models.swift */; }; + 3C4AB8999BDD4D5E98612A5B /* ApiClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 528F82E4F9DA437F8A507302 /* ApiClient.swift */; }; + EEBC04021DF84B66B3D78813 /* SessionStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C5B25BEDABD47DD88AA0AE8 /* SessionStore.swift */; }; + A8E4C71F2B9A4D6E8F0C1D2E /* FavoritesStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D2E3F4A5B647C8D9E0F1A2 /* FavoritesStore.swift */; }; + 95D6B4E042044B919002684A /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E44A28ED2FE842AEA848392F /* AppModel.swift */; }; + D70A4268B1AA40ACADDFF04B /* McTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3215184C370A49AB83E53332 /* McTheme.swift */; }; + A6FDE760B2484854BAE25395 /* ProfileAvatars.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BE72D16CFAE415F9B52FE43 /* ProfileAvatars.swift */; }; + 424E70D5A34B48EFA6EAE5E7 /* VLCPlayerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED163B837C404A1183E20226 /* VLCPlayerController.swift */; }; + 15ABF9F7AD2B4D04B118C331 /* PlayerScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BFA8FFCE77B497FBE503CA0 /* PlayerScreen.swift */; }; + F8AE29FF63524E7D8C036C7E /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 522137D80C53480E95FF600F /* RootView.swift */; }; + D5D29939C5A64A23AF7AD046 /* MainShell.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF5E450343884C11B0920888 /* MainShell.swift */; }; + 1EF110B9F82E440F999ECE37 /* Components.swift in Sources */ = {isa = PBXBuildFile; fileRef = 314B20E94E6B4034B1EF282B /* Components.swift */; }; + E3C7ACAC5AC74107B90C6892 /* LinkScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19304FA5C7624099B8E97E1F /* LinkScreen.swift */; }; + EEB314675D0049E6B289021D /* ProfilesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFD53AA4D5C9400383EE7DC7 /* ProfilesScreen.swift */; }; + AE1CD4524BFF41A0ACB1FDDA /* HomeScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5561954288524F7C9E41CA40 /* HomeScreen.swift */; }; + E2F8D0CF0F39406FB37884A5 /* BrowseScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = E44909AE0F474047ADB06E27 /* BrowseScreen.swift */; }; + CA1BAD3B015E434C85D51CD2 /* SearchScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = F950D46F720C4C359184B2CF /* SearchScreen.swift */; }; + 668CADCCEA404B50BB19A46E /* DetailScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A435950EF040DE83F30779 /* DetailScreens.swift */; }; + A72DE8139DAE48EC9975E0CF /* LiveTvScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98E3A2A592E64C998AB1B72A /* LiveTvScreen.swift */; }; + A1CFC814F3894773A5BEA652 /* SettingsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 470B4DF416DE4B3FB7A60DDB /* SettingsScreen.swift */; }; + B7C2E91A4F8D4A1E9C3B6D20 /* QualityBadges.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4A1F82E6B9C4E7A8F1D3C50 /* QualityBadges.swift */; }; + 468E05CB91FC4A1997A2F386 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6FA95D6A03BF48EA97553486 /* Assets.xcassets */; }; + DC8D7BF328134E1E8D7E3D76 /* MobileVLCKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = BE257D7C209E4803903206A8 /* MobileVLCKit.xcframework */; }; + E9BB0F1F350A442FB6440AE5 /* MobileVLCKit.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = BE257D7C209E4803903206A8 /* MobileVLCKit.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + CC6F977475F647EAB526B851 /* VMCApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMCApp.swift; sourceTree = ""; }; + C2EA13EA1EAB446784C29601 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = ""; }; + 528F82E4F9DA437F8A507302 /* ApiClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiClient.swift; sourceTree = ""; }; + 1C5B25BEDABD47DD88AA0AE8 /* SessionStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionStore.swift; sourceTree = ""; }; + B1D2E3F4A5B647C8D9E0F1A2 /* FavoritesStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesStore.swift; sourceTree = ""; }; + E44A28ED2FE842AEA848392F /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = ""; }; + 3215184C370A49AB83E53332 /* McTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = McTheme.swift; sourceTree = ""; }; + 8BE72D16CFAE415F9B52FE43 /* ProfileAvatars.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileAvatars.swift; sourceTree = ""; }; + ED163B837C404A1183E20226 /* VLCPlayerController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VLCPlayerController.swift; sourceTree = ""; }; + 2BFA8FFCE77B497FBE503CA0 /* PlayerScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerScreen.swift; sourceTree = ""; }; + 522137D80C53480E95FF600F /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; + BF5E450343884C11B0920888 /* MainShell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainShell.swift; sourceTree = ""; }; + 314B20E94E6B4034B1EF282B /* Components.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Components.swift; sourceTree = ""; }; + 19304FA5C7624099B8E97E1F /* LinkScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkScreen.swift; sourceTree = ""; }; + EFD53AA4D5C9400383EE7DC7 /* ProfilesScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilesScreen.swift; sourceTree = ""; }; + 5561954288524F7C9E41CA40 /* HomeScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreen.swift; sourceTree = ""; }; + E44909AE0F474047ADB06E27 /* BrowseScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseScreen.swift; sourceTree = ""; }; + F950D46F720C4C359184B2CF /* SearchScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchScreen.swift; sourceTree = ""; }; + F0A435950EF040DE83F30779 /* DetailScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailScreens.swift; sourceTree = ""; }; + 98E3A2A592E64C998AB1B72A /* LiveTvScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTvScreen.swift; sourceTree = ""; }; + 470B4DF416DE4B3FB7A60DDB /* SettingsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreen.swift; sourceTree = ""; }; + D4A1F82E6B9C4E7A8F1D3C50 /* QualityBadges.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QualityBadges.swift; sourceTree = ""; }; + 6FA95D6A03BF48EA97553486 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + A22966A1CCBE44999F75FCA2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + BE257D7C209E4803903206A8 /* MobileVLCKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = MobileVLCKit.xcframework; path = Vendor/MobileVLCKit.xcframework; sourceTree = ""; }; + E3BC8A7BDE75425E84A174DE /* VMC.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VMC.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXCopyFilesBuildPhase section */ + E12A5545817A4B6CBDEA1B65 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + E9BB0F1F350A442FB6440AE5 /* MobileVLCKit.xcframework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFrameworksBuildPhase section */ + 2D5042DB0EBC4220AB94160F /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + DC8D7BF328134E1E8D7E3D76 /* MobileVLCKit.xcframework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 38C61E99A2324F80A45248A3 = { + isa = PBXGroup; + children = ( + 7200A95B2D6F482FA1FDA1A1 /* VMC */, + BE257D7C209E4803903206A8 /* MobileVLCKit.xcframework */, + AA621A418F3940A281D959B4 /* Products */, + ); + sourceTree = ""; + }; + AA621A418F3940A281D959B4 /* Products */ = { + isa = PBXGroup; + children = ( + E3BC8A7BDE75425E84A174DE /* VMC.app */, + ); + name = Products; + sourceTree = ""; + }; + 7200A95B2D6F482FA1FDA1A1 /* VMC */ = { + isa = PBXGroup; + children = ( + CC6F977475F647EAB526B851 /* VMCApp.swift */, + 8834A61BE0484DC5AB28E4D5 /* Data */, + 92370C3FECCC4F208F2DE9FF /* Theme */, + F8B60A829DE04A098A56BD06 /* Playback */, + B88BA411548F4D309F8AD1B1 /* UI */, + 6FA95D6A03BF48EA97553486 /* Assets.xcassets */, + A22966A1CCBE44999F75FCA2 /* Info.plist */, + ); + path = VMC; + sourceTree = ""; + }; + 8834A61BE0484DC5AB28E4D5 /* Data */ = { + isa = PBXGroup; + children = ( + C2EA13EA1EAB446784C29601 /* Models.swift */, + 528F82E4F9DA437F8A507302 /* ApiClient.swift */, + 1C5B25BEDABD47DD88AA0AE8 /* SessionStore.swift */, + B1D2E3F4A5B647C8D9E0F1A2 /* FavoritesStore.swift */, + E44A28ED2FE842AEA848392F /* AppModel.swift */, + ); + path = Data; + sourceTree = ""; + }; + 92370C3FECCC4F208F2DE9FF /* Theme */ = { + isa = PBXGroup; + children = ( + 3215184C370A49AB83E53332 /* McTheme.swift */, + 8BE72D16CFAE415F9B52FE43 /* ProfileAvatars.swift */, + ); + path = Theme; + sourceTree = ""; + }; + F8B60A829DE04A098A56BD06 /* Playback */ = { + isa = PBXGroup; + children = ( + ED163B837C404A1183E20226 /* VLCPlayerController.swift */, + 2BFA8FFCE77B497FBE503CA0 /* PlayerScreen.swift */, + ); + path = Playback; + sourceTree = ""; + }; + B88BA411548F4D309F8AD1B1 /* UI */ = { + isa = PBXGroup; + children = ( + 522137D80C53480E95FF600F /* RootView.swift */, + BF5E450343884C11B0920888 /* MainShell.swift */, + 314B20E94E6B4034B1EF282B /* Components.swift */, + 19304FA5C7624099B8E97E1F /* LinkScreen.swift */, + EFD53AA4D5C9400383EE7DC7 /* ProfilesScreen.swift */, + 5561954288524F7C9E41CA40 /* HomeScreen.swift */, + E44909AE0F474047ADB06E27 /* BrowseScreen.swift */, + F950D46F720C4C359184B2CF /* SearchScreen.swift */, + F0A435950EF040DE83F30779 /* DetailScreens.swift */, + D4A1F82E6B9C4E7A8F1D3C50 /* QualityBadges.swift */, + 98E3A2A592E64C998AB1B72A /* LiveTvScreen.swift */, + 470B4DF416DE4B3FB7A60DDB /* SettingsScreen.swift */, + ); + path = UI; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 298AE872C0A34A05AEE235D3 /* VMC */ = { + isa = PBXNativeTarget; + buildConfigurationList = DAB4A9178D1E47A2B4C39A34 /* Build configuration list for PBXNativeTarget "VMC" */; + buildPhases = ( + 64001BDBED674892AAE2608D /* Sources */, + 2D5042DB0EBC4220AB94160F /* Frameworks */, + E12A5545817A4B6CBDEA1B65 /* Embed Frameworks */, + 696F3162103C414A8196FF86 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = VMC; + productName = VMC; + productReference = E3BC8A7BDE75425E84A174DE /* VMC.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 9ADB1808F04342F4A56B7876 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1600; + LastUpgradeCheck = 1600; + TargetAttributes = { + 298AE872C0A34A05AEE235D3 = { + CreatedOnToolsVersion = 16.0; + }; + }; + }; + buildConfigurationList = 9336F84198EE4F5183B5EA81 /* Build configuration list for PBXProject "VMC" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = nl; + hasScannedForEncodings = 0; + knownRegions = ( + en, + nl, + Base, + ); + mainGroup = 38C61E99A2324F80A45248A3; + productRefGroup = AA621A418F3940A281D959B4 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 298AE872C0A34A05AEE235D3 /* VMC */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 696F3162103C414A8196FF86 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 468E05CB91FC4A1997A2F386 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 64001BDBED674892AAE2608D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 6BE9B837C256472081A772BB /* VMCApp.swift in Sources */, + 99D51124A40D4FB6BB32E20E /* Models.swift in Sources */, + 3C4AB8999BDD4D5E98612A5B /* ApiClient.swift in Sources */, + EEBC04021DF84B66B3D78813 /* SessionStore.swift in Sources */, + A8E4C71F2B9A4D6E8F0C1D2E /* FavoritesStore.swift in Sources */, + 95D6B4E042044B919002684A /* AppModel.swift in Sources */, + D70A4268B1AA40ACADDFF04B /* McTheme.swift in Sources */, + A6FDE760B2484854BAE25395 /* ProfileAvatars.swift in Sources */, + 424E70D5A34B48EFA6EAE5E7 /* VLCPlayerController.swift in Sources */, + 15ABF9F7AD2B4D04B118C331 /* PlayerScreen.swift in Sources */, + F8AE29FF63524E7D8C036C7E /* RootView.swift in Sources */, + D5D29939C5A64A23AF7AD046 /* MainShell.swift in Sources */, + 1EF110B9F82E440F999ECE37 /* Components.swift in Sources */, + E3C7ACAC5AC74107B90C6892 /* LinkScreen.swift in Sources */, + EEB314675D0049E6B289021D /* ProfilesScreen.swift in Sources */, + AE1CD4524BFF41A0ACB1FDDA /* HomeScreen.swift in Sources */, + E2F8D0CF0F39406FB37884A5 /* BrowseScreen.swift in Sources */, + CA1BAD3B015E434C85D51CD2 /* SearchScreen.swift in Sources */, + 668CADCCEA404B50BB19A46E /* DetailScreens.swift in Sources */, + B7C2E91A4F8D4A1E9C3B6D20 /* QualityBadges.swift in Sources */, + A72DE8139DAE48EC9975E0CF /* LiveTvScreen.swift in Sources */, + A1CFC814F3894773A5BEA652 /* SettingsScreen.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + B84CDAF8B6194DFDBBE92709 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_TESTABILITY = YES; + GCC_DYNAMIC_NO_PIC = NO; + GCC_OPTIMIZATION_LEVEL = 0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + F8105C88D82F4B00A12C4986 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 1A09171857554F15A1C0F306 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 7; + DEVELOPMENT_TEAM = ""; + ENABLE_BITCODE = NO; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = VMC/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Vendor" + ); + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks" + ); + MARKETING_VERSION = 0.4.3; + OTHER_LDFLAGS = ("$(inherited)", "-ObjC"); + PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 28C86F57859F4C83A316FB8F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 7; + DEVELOPMENT_TEAM = ""; + ENABLE_BITCODE = NO; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = VMC/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Vendor" + ); + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks" + ); + MARKETING_VERSION = 0.4.3; + OTHER_LDFLAGS = ("$(inherited)", "-ObjC"); + PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 9336F84198EE4F5183B5EA81 /* Build configuration list for PBXProject "VMC" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + B84CDAF8B6194DFDBBE92709 /* Debug */, + F8105C88D82F4B00A12C4986 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + DAB4A9178D1E47A2B4C39A34 /* Build configuration list for PBXNativeTarget "VMC" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 1A09171857554F15A1C0F306 /* Debug */, + 28C86F57859F4C83A316FB8F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 9ADB1808F04342F4A56B7876 /* Project object */; +} diff --git a/apps/ios/VMC/Assets.xcassets/AccentColor.colorset/Contents.json b/apps/ios/VMC/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..64ce80f --- /dev/null +++ b/apps/ios/VMC/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.078", + "green" : "0.035", + "red" : "0.898" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/VMC/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/apps/ios/VMC/Assets.xcassets/AppIcon.appiconset/AppIcon.png new file mode 100644 index 0000000..75dbd32 Binary files /dev/null and b/apps/ios/VMC/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ diff --git a/apps/ios/VMC/Assets.xcassets/AppIcon.appiconset/Contents.json b/apps/ios/VMC/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..cefcc87 --- /dev/null +++ b/apps/ios/VMC/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/VMC/Assets.xcassets/Contents.json b/apps/ios/VMC/Assets.xcassets/Contents.json new file mode 100644 index 0000000..e3723b1 --- /dev/null +++ b/apps/ios/VMC/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "Vonas", + "version" : 1 + } +} diff --git a/apps/ios/VMC/Data/ApiClient.swift b/apps/ios/VMC/Data/ApiClient.swift new file mode 100644 index 0000000..326d375 --- /dev/null +++ b/apps/ios/VMC/Data/ApiClient.swift @@ -0,0 +1,303 @@ +import Foundation + +enum ApiError: LocalizedError { + case message(String) + case badStatus(Int, String) + + var errorDescription: String? { + switch self { + case .message(let m): return m + case .badStatus(let code, let body): return body.isEmpty ? "HTTP \(code)" : body + } + } +} + +actor ApiClient { + private var baseUrl: String + private var token: String? + private let session: URLSession + + init(baseUrl: String = SessionStore.defaultApiBase, token: String? = nil) { + self.baseUrl = baseUrl.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + self.token = token + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 30 + config.timeoutIntervalForResource = 60 + self.session = URLSession(configuration: config) + } + + func configure(baseUrl: String, token: String?) { + self.baseUrl = baseUrl.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + self.token = token + } + + func createDeviceCode(deviceName: String) async throws -> DeviceCodeResponse { + try await post("/api/v1/client/device/code", json: [ + "deviceName": deviceName, + "platform": "ios", + ], authed: false) + } + + func pollDeviceCode(code: String) async throws -> PollResult { + let encoded = code.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? code + let data = try await getData("/api/v1/client/device/poll?code=\(encoded)", authed: false) + let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:] + switch (obj["status"] as? String) ?? "" { + case "linked": + let token = obj["refreshToken"] as? String ?? "" + let viewer = obj["viewer"] as? [String: Any] + let name = (viewer?["name"] as? String) ?? (viewer?["email"] as? String) ?? "Kijker" + return .linked(refreshToken: token, viewerName: name) + case "expired": return .expired + case "already_linked": return .alreadyLinked + default: return .pending + } + } + + func home() async throws -> HomePayload { try await get("/api/v1/client/home") } + + func search(query: String) async throws -> [MediaItem] { + let q = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? query + let payload: SearchPayload = try await get("/api/v1/client/search?q=\(q)&take=60") + return payload.results + } + + func browse(catalogId: String, skip: Int = 0, take: Int = 60) async throws -> BrowsePayload { + let id = catalogId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? catalogId + return try await get("/api/v1/client/catalog/\(id)?skip=\(skip)&take=\(take)") + } + + func movie(id: String) async throws -> MovieDetail { + let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id + return try await get("/api/v1/client/movies/\(encoded)") + } + + func series(id: String) async throws -> SeriesDetail { + let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id + return try await get("/api/v1/client/series/\(encoded)") + } + + func play(mediaFileId: String) async throws -> PlayResult { + try await post("/api/v1/client/play", json: ["mediaFileId": mediaFileId]) + } + + func postProgress( + mediaType: String, + movieId: String? = nil, + episodeId: String? = nil, + seriesId: String? = nil, + positionMs: Int64, + durationMs: Int64 + ) async throws { + var body: [String: Any] = [ + "mediaType": mediaType, + "positionMs": positionMs, + "durationMs": durationMs, + ] + if let movieId { body["movieId"] = movieId } + if let episodeId { body["episodeId"] = episodeId } + if let seriesId { body["seriesId"] = seriesId } + let _: Empty = try await post("/api/v1/client/progress", json: body) + } + + func listProfiles() async throws -> ProfilesPayload { try await get("/api/v1/client/profiles") } + + func selectProfile(profileId: String, pin: String? = nil) async throws -> ViewerProfile { + var body: [String: Any] = [:] + if let pin { + let digits = pin.filter(\.isNumber) + if !digits.isEmpty { body["pin"] = String(digits) } + } + let encoded = profileId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? profileId + struct Wrap: Decodable { let profile: ViewerProfile } + let wrap: Wrap = try await post("/api/v1/client/profiles/\(encoded)/select", json: body) + return wrap.profile + } + + func clearProfile() async throws { + let _: Empty = try await post("/api/v1/client/profiles/clear", json: [:]) + } + + func createProfile(name: String, avatarId: String, isKids: Bool, pin: String?) async throws -> ViewerProfile { + var body: [String: Any] = [ + "name": name, + "avatarId": avatarId, + "isKids": isKids, + ] + if let pin { + let digits = pin.filter(\.isNumber) + if digits.count == 4 { body["pin"] = String(digits) } + } + struct Wrap: Decodable { let profile: ViewerProfile } + let wrap: Wrap = try await post("/api/v1/client/profiles", json: body) + return wrap.profile + } + + func updateProfile( + profileId: String, + name: String? = nil, + avatarId: String? = nil, + isKids: Bool? = nil, + pin: String? = nil, + clearPin: Bool = false + ) async throws -> ViewerProfile { + var body: [String: Any] = [:] + if let name { body["name"] = name } + if let avatarId { body["avatarId"] = avatarId } + if let isKids { body["isKids"] = isKids } + if clearPin { + body["clearPin"] = true + } else if let pin { + let digits = pin.filter(\.isNumber) + if digits.count == 4 { body["pin"] = String(digits) } + } + let encoded = profileId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? profileId + struct Wrap: Decodable { let profile: ViewerProfile } + let wrap: Wrap = try await patch("/api/v1/client/profiles/\(encoded)", json: body) + return wrap.profile + } + + func deleteProfile(profileId: String) async throws { + let encoded = profileId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? profileId + let _: Empty = try await requestEmpty("/api/v1/client/profiles/\(encoded)", method: "DELETE") + } + + func getShelfPrefs() async throws -> [ShelfPref] { + struct Wrap: Decodable { let shelves: [ShelfPref] } + let wrap: Wrap = try await get("/api/v1/client/shelves/prefs") + return wrap.shelves + } + + func saveShelfPrefs(_ prefs: [ShelfPref]) async throws { + let shelves: [[String: Any]] = prefs.enumerated().map { index, p in + [ + "shelfId": p.shelfId, + "visible": p.visible, + "sortOrder": p.sortOrder >= 0 ? p.sortOrder : index, + ] + } + _ = try await requestData( + "/api/v1/client/shelves/prefs", + method: "PUT", + json: ["shelves": shelves], + authed: true + ) + } + + func listFavorites() async throws -> [MediaItem] { + struct Wrap: Decodable { let items: [MediaItem] } + let wrap: Wrap = try await get("/api/v1/client/favorites") + return wrap.items + } + + func toggleFavorite(type: String, id: String) async throws -> FavoriteToggleResult { + try await post("/api/v1/client/favorites/toggle", json: [ + "type": type, + "id": id, + ]) + } + + func addFavorite(type: String, id: String) async throws -> FavoriteToggleResult { + try await post("/api/v1/client/favorites", json: [ + "type": type, + "id": id, + ]) + } + + func clientMe() async throws -> ClientMe { try await get("/api/v1/client/me") } + + func searchSubtitles(mediaFileId: String, languages: String = "nl,en") async throws -> SubtitleSearchResult { + let id = mediaFileId.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? mediaFileId + let langs = languages.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? languages + return try await get("/api/v1/client/subtitles/search?mediaFileId=\(id)&languages=\(langs)") + } + + func fetchSubtitle(fileId: Int, source: String) async throws -> FetchedSubtitle { + try await post("/api/v1/client/subtitles/fetch", json: ["fileId": fileId, "source": source]) + } + + func loadIptvCategories() async throws -> [IptvCategory] { + struct Wrap: Decodable { let categories: [IptvCategory] } + let wrap: Wrap = try await get("/api/v1/client/iptv/categories") + return wrap.categories + } + + func iptvChannels(categoryId: String? = nil, includeEpg: Bool = true) async throws -> [IptvChannel] { + var params: [String] = [] + if let categoryId { + let enc = categoryId.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? categoryId + params.append("categoryId=\(enc)") + } + if includeEpg { params.append("epg=1") } + let q = params.isEmpty ? "" : "?\(params.joined(separator: "&"))" + struct Wrap: Decodable { let channels: [IptvChannel] } + let wrap: Wrap = try await get("/api/v1/client/iptv/channels\(q)") + return wrap.channels + } + + func iptvPlay(streamId: Int) async throws -> IptvPlayInfo { + try await get("/api/v1/client/iptv/channels/\(streamId)/play") + } + + // MARK: - HTTP + + private struct Empty: Decodable {} + + private func get(_ path: String, authed: Bool = true) async throws -> T { + let data = try await getData(path, authed: authed) + return try JSONDecoder().decode(T.self, from: data) + } + + private func post(_ path: String, json: [String: Any], authed: Bool = true) async throws -> T { + let data = try await requestData(path, method: "POST", json: json, authed: authed) + if T.self == Empty.self { return Empty() as! T } + if data.isEmpty { throw ApiError.message("Leeg antwoord") } + return try JSONDecoder().decode(T.self, from: data) + } + + private func patch(_ path: String, json: [String: Any], authed: Bool = true) async throws -> T { + let data = try await requestData(path, method: "PATCH", json: json, authed: authed) + return try JSONDecoder().decode(T.self, from: data) + } + + private func requestEmpty(_ path: String, method: String) async throws -> Empty { + _ = try await requestData(path, method: method, json: nil, authed: true) + return Empty() + } + + private func getData(_ path: String, authed: Bool) async throws -> Data { + try await requestData(path, method: "GET", json: nil, authed: authed) + } + + private func requestData( + _ path: String, + method: String, + json: [String: Any]?, + authed: Bool + ) async throws -> Data { + guard let url = URL(string: baseUrl + path) else { + throw ApiError.message("Ongeldige URL") + } + var req = URLRequest(url: url) + req.httpMethod = method + req.setValue("application/json", forHTTPHeaderField: "Accept") + if let json { + req.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type") + req.httpBody = try JSONSerialization.data(withJSONObject: json) + } + if authed, let token, !token.isEmpty { + req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + let (data, response) = try await session.data(for: req) + guard let http = response as? HTTPURLResponse else { + throw ApiError.message("Geen HTTP-antwoord") + } + guard (200..<300).contains(http.statusCode) else { + let msg = (try? JSONDecoder().decode(ApiErrorBody.self, from: data))?.error?.message + ?? String(data: data, encoding: .utf8) + ?? "HTTP \(http.statusCode)" + throw ApiError.badStatus(http.statusCode, msg) + } + return data + } +} diff --git a/apps/ios/VMC/Data/AppModel.swift b/apps/ios/VMC/Data/AppModel.swift new file mode 100644 index 0000000..84e61eb --- /dev/null +++ b/apps/ios/VMC/Data/AppModel.swift @@ -0,0 +1,147 @@ +import Foundation +import UIKit +import Combine + +@MainActor +final class AppModel: ObservableObject { + enum Phase: Equatable { + case boot + case link + case profiles + case main + } + + enum MainTab: String, CaseIterable, Identifiable { + case home, movies, series, tv, search, account + var id: String { rawValue } + + var title: String { + switch self { + case .home: return "Home" + case .movies: return "Films" + case .series: return "Series" + case .tv: return "TV" + case .search: return "Zoeken" + case .account: return "Account" + } + } + + var icon: String { + switch self { + case .home: return "house.fill" + case .movies: return "film" + case .series: return "rectangle.stack.fill" + case .tv: return "tv.fill" + case .search: return "magnifyingglass" + case .account: return "person.crop.circle.fill" + } + } + } + + let store = SessionStore() + let api = ApiClient() + let favorites = FavoritesStore() + + @Published var phase: Phase = .boot + @Published var tab: MainTab = .home + @Published var iptvEnabled = false + @Published var canManageProfiles = false + @Published var bootError: String? + /// > 0 terwijl een speler (VOD/live) open is — verbergt de footer. + @Published private(set) var playbackDepth = 0 + + var isPlaybackActive: Bool { playbackDepth > 0 } + + func enterPlayback() { playbackDepth += 1 } + func leavePlayback() { playbackDepth = max(0, playbackDepth - 1) } + + private var cancellables = Set() + + init() { + favorites.objectWillChange + .receive(on: RunLoop.main) + .sink { [weak self] _ in self?.objectWillChange.send() } + .store(in: &cancellables) + Task { await bootstrap() } + } + + func bootstrap() async { + phase = .boot + bootError = nil + await api.configure(baseUrl: store.apiBase, token: store.refreshToken) + guard store.isAuthed else { + phase = .link + return + } + do { + let me = try await api.clientMe() + iptvEnabled = me.iptvEnabled + if let profile = me.profile { + store.setProfile(id: profile.id, name: profile.name, avatarId: profile.avatarId) + favorites.bind(profileId: profile.id) + await favorites.refresh(using: api) + if let payload = try? await api.listProfiles() { + canManageProfiles = payload.canManage == true + } else { + canManageProfiles = profile.isOwner == true + } + phase = .main + } else { + store.setProfile(id: nil, name: nil, avatarId: nil) + favorites.bind(profileId: nil) + canManageProfiles = false + phase = .profiles + } + } catch { + bootError = error.localizedDescription + store.setToken(nil) + favorites.bind(profileId: nil) + await api.configure(baseUrl: store.apiBase, token: nil) + phase = .link + } + } + + func didLink(token: String) async { + store.setToken(token) + await api.configure(baseUrl: store.apiBase, token: token) + store.setProfile(id: nil, name: nil, avatarId: nil) + if let me = try? await api.clientMe() { + iptvEnabled = me.iptvEnabled + } + phase = .profiles + } + + func didSelectProfile(_ profile: ViewerProfile) { + store.setProfile(id: profile.id, name: profile.name, avatarId: profile.avatarId) + favorites.bind(profileId: profile.id) + Task { + await favorites.refresh(using: api) + if let payload = try? await api.listProfiles() { + canManageProfiles = payload.canManage == true + } else { + canManageProfiles = profile.isOwner == true + } + } + tab = .home + phase = .main + } + + func switchProfile() async { + try? await api.clearProfile() + store.setProfile(id: nil, name: nil, avatarId: nil) + favorites.bind(profileId: nil) + phase = .profiles + } + + func logout() async { + store.setToken(nil) + await api.configure(baseUrl: store.apiBase, token: nil) + favorites.bind(profileId: nil) + iptvEnabled = false + phase = .link + } + + static var deviceName: String { + UIDevice.current.name.isEmpty ? "iPhone" : UIDevice.current.name + } +} diff --git a/apps/ios/VMC/Data/FavoritesStore.swift b/apps/ios/VMC/Data/FavoritesStore.swift new file mode 100644 index 0000000..3bd4e59 --- /dev/null +++ b/apps/ios/VMC/Data/FavoritesStore.swift @@ -0,0 +1,205 @@ +import Foundation +import SwiftUI +import UIKit + +@MainActor +final class FavoritesStore: ObservableObject { + @Published private(set) var items: [MediaItem] = [] + + private var profileId: String? + private let defaults = UserDefaults.standard + + private func cacheKey(for profileId: String) -> String { + "vmc.favorites.cache.\(profileId)" + } + + private func migratedKey(for profileId: String) -> String { + "vmc.favorites.migrated.\(profileId)" + } + + /// Oude lokale key (vóór server-sync) — eenmalig uploaden. + private func legacyKey(for profileId: String) -> String { + "vmc.favorites.\(profileId)" + } + + func bind(profileId: String?) { + self.profileId = profileId + guard let profileId, !profileId.isEmpty else { + items = [] + return + } + items = loadCache(profileId: profileId) + } + + func contains(type: String, id: String) -> Bool { + let want = normalizedType(type) + return items.contains { + $0.id == id && normalizedType($0.type) == want + } + } + + private func normalizedType(_ type: String) -> String { + let t = type.lowercased() + return (t == "series" || t == "show") ? "series" : "movie" + } + + func replaceAll(_ next: [MediaItem]) { + items = next + persistCache() + } + + /// Optimistic + server sync. Retourneert of het item nu favoriet is. + @discardableResult + func toggle( + type: String, + id: String, + title: String, + year: Int?, + posterUrl: String?, + backdropUrl: String?, + logoUrl: String?, + api: ApiClient + ) async -> Bool { + let normalized = normalizedType(type) + let wasOn = contains(type: normalized, id: id) + + if wasOn { + items.removeAll { $0.id == id && normalizedType($0.type) == normalized } + } else { + let item = MediaItem( + type: normalized.uppercased() == "SERIES" ? "SERIES" : "MOVIE", + id: id, + title: title, + subtitle: nil, + year: year, + posterUrl: posterUrl, + backdropUrl: backdropUrl, + logoUrl: logoUrl, + mediaFileId: nil, + seriesId: nil, + positionMs: nil, + durationMs: nil, + progressId: nil, + badge: nil + ) + items.insert(item, at: 0) + } + persistCache() + + do { + let result = try await api.toggleFavorite(type: normalized, id: id) + if let remote = result.items { + replaceAll(remote) + } else { + try await refresh(using: api) + } + return result.favorited + } catch { + // Rollback optimistic update + if wasOn { + let item = MediaItem( + type: normalized.uppercased() == "SERIES" ? "SERIES" : "MOVIE", + id: id, + title: title, + subtitle: nil, + year: year, + posterUrl: posterUrl, + backdropUrl: backdropUrl, + logoUrl: logoUrl, + mediaFileId: nil, + seriesId: nil, + positionMs: nil, + durationMs: nil, + progressId: nil, + badge: nil + ) + items.insert(item, at: 0) + } else { + items.removeAll { $0.id == id && normalizedType($0.type) == normalized } + } + persistCache() + return wasOn + } + } + + func refresh(using api: ApiClient) async { + guard let profileId, !profileId.isEmpty else { + items = [] + return + } + do { + await migrateLegacyIfNeeded(profileId: profileId, api: api) + let remote = try await api.listFavorites() + replaceAll(remote) + } catch { + // Houd cache aan bij netwerkfout + if items.isEmpty { + items = loadCache(profileId: profileId) + } + } + } + + var mediaItems: [MediaItem] { items } + + private func migrateLegacyIfNeeded(profileId: String, api: ApiClient) async { + let flag = migratedKey(for: profileId) + guard !defaults.bool(forKey: flag) else { return } + defer { defaults.set(true, forKey: flag) } + + guard let data = defaults.data(forKey: legacyKey(for: profileId)), + let legacy = try? JSONDecoder().decode([LegacyFavorite].self, from: data), + !legacy.isEmpty + else { return } + + for item in legacy { + let t = item.type.lowercased() == "series" ? "series" : "movie" + _ = try? await api.addFavorite(type: t, id: item.mediaId) + } + defaults.removeObject(forKey: legacyKey(for: profileId)) + } + + private func loadCache(profileId: String) -> [MediaItem] { + guard let data = defaults.data(forKey: cacheKey(for: profileId)), + let decoded = try? JSONDecoder().decode([MediaItem].self, from: data) + else { return [] } + return decoded + } + + private func persistCache() { + guard let profileId, !profileId.isEmpty else { return } + if let data = try? JSONEncoder().encode(items) { + defaults.set(data, forKey: cacheKey(for: profileId)) + } + } +} + +private struct LegacyFavorite: Codable { + let type: String + let mediaId: String +} + +struct BookmarkToggleButton: View { + let isOn: Bool + let action: () -> Void + + var body: some View { + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + withAnimation(.spring(response: 0.32, dampingFraction: 0.7)) { + action() + } + } label: { + Image(systemName: isOn ? "bookmark.fill" : "bookmark") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(isOn ? McColors.accent : McColors.text) + .frame(width: 52, height: 52) + .background(McColors.surface, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .strokeBorder(isOn ? McColors.accent.opacity(0.45) : Color.white.opacity(0.08), lineWidth: 1) + } + } + .buttonStyle(.plain) + .accessibilityLabel(isOn ? "Verwijder uit favorieten" : "Bewaar in favorieten") + } +} diff --git a/apps/ios/VMC/Data/Models.swift b/apps/ios/VMC/Data/Models.swift new file mode 100644 index 0000000..bebb677 --- /dev/null +++ b/apps/ios/VMC/Data/Models.swift @@ -0,0 +1,232 @@ +import Foundation + +struct DeviceCodeResponse: Decodable { + let code: String + let expiresAt: String? + let pollIntervalSeconds: Int? + let linkUrl: String? + let qrPayload: String? +} + +enum PollResult { + case pending + case expired + case alreadyLinked + case linked(refreshToken: String, viewerName: String) +} + +struct HomePayload: Decodable { + let continueWatching: [MediaItem] + let spotlight: [MediaItem]? + let shelves: [ShelfRow] + let favorites: [MediaItem]? +} + +struct ShelfRow: Decodable, Identifiable { + let id: String + let title: String + let name: String? + let kind: String? + let slug: String? + let items: [MediaItem] +} + +struct MediaItem: Codable, Identifiable, Hashable { + let type: String + let id: String + let title: String + let subtitle: String? + let year: Int? + let posterUrl: String? + let backdropUrl: String? + let logoUrl: String? + let mediaFileId: String? + let seriesId: String? + let positionMs: Int64? + let durationMs: Int64? + let progressId: String? + let badge: String? +} + +struct PlayResult: Decodable { + let streamUrl: String + let sessionId: String? + let mediaFileId: String? +} + +struct ViewerProfile: Decodable, Identifiable, Hashable { + let id: String + let name: String + let avatarId: String? + let isOwner: Bool? + let isKids: Bool? + let hasPin: Bool? +} + +struct ProfilesPayload: Decodable { + let profiles: [ViewerProfile] + let activeProfileId: String? + let canManage: Bool? +} + +struct ClientMe: Decodable { + struct Iptv: Decodable { let enabled: Bool? } + let iptv: Iptv? + let profile: ViewerProfile? + + var iptvEnabled: Bool { iptv?.enabled == true } +} + +struct MediaFileInfo: Decodable, Identifiable, Hashable { + let id: String + let label: String? + let resolution: String? + let videoCodec: String? + let audioCodec: String? + let hdrType: String? + let nodeOnline: Bool? + let nodeName: String? +} + +struct MovieDetail: Decodable { + let id: String + let title: String + let overview: String? + let year: Int? + let posterUrl: String? + let backdropUrl: String? + let logoUrl: String? + let runtime: Int? + let genres: [String]? + let rating: String? + let cast: [String]? + let director: [String]? + let mediaFiles: [MediaFileInfo]? + + var preferredMediaFileId: String? { + mediaFiles?.first(where: { $0.nodeOnline != false })?.id ?? mediaFiles?.first?.id + } +} + +struct SeriesDetail: Decodable { + let id: String + let title: String + let overview: String? + let year: Int? + let posterUrl: String? + let backdropUrl: String? + let logoUrl: String? + let genres: [String]? + let rating: String? + let cast: [String]? + let director: [String]? + let seasons: [Season]? +} + +struct Season: Decodable, Identifiable { + var id: Int { seasonNumber } + let seasonNumber: Int + let episodes: [Episode]? +} + +struct Episode: Decodable, Identifiable { + let id: String + let title: String? + let episodeNumber: Int? + let seasonNumber: Int? + let overview: String? + let stillUrl: String? + let mediaFileId: String? + let available: Bool? + let runtime: Int? +} + +struct SearchPayload: Decodable { + let results: [MediaItem] +} + +struct BrowsePayload: Decodable { + let id: String? + let title: String? + let kind: String? + let total: Int? + let items: [MediaItem] +} + +struct SubtitleHit: Decodable, Identifiable, Hashable { + var id: String { "\(source ?? "x")-\(fileId)" } + let fileId: Int + let language: String? + let release: String? + let fileName: String? + let downloadCount: Int? + let hearingImpaired: Bool? + let source: String? + let uploader: String? +} + +struct SubtitleSearchResult: Decodable { + let configured: Bool? + let imdbId: String? + let title: String? + let results: [SubtitleHit] +} + +struct FetchedSubtitle: Decodable { + let fileId: Int? + let source: String? + let fileName: String? + let format: String? + let contentBase64: String +} + +struct IptvCategory: Decodable, Identifiable, Hashable { + let id: String + let name: String + let channelCount: Int? +} + +struct IptvChannel: Decodable, Identifiable, Hashable { + var id: Int { streamId } + let streamId: Int + let name: String + let logoUrl: String? + let number: Int? + let categoryId: String? + let nowTitle: String? +} + +struct IptvPlayInfo: Decodable { + let streamId: Int + let name: String + let logoUrl: String? + let streamUrl: String + let format: String? + let fallbackStreamUrl: String? + let fallbackFormat: String? +} + +struct ShelfPref: Codable, Identifiable, Hashable { + var id: String { shelfId } + let shelfId: String + let name: String + let slug: String? + let kind: String? + var visible: Bool + var sortOrder: Int +} + +struct FavoriteToggleResult: Decodable { + let favorited: Bool + let items: [MediaItem]? +} + +struct ApiErrorBody: Decodable { + struct ErrorObj: Decodable { let message: String? } + let error: ErrorObj? +} + +struct PlayerTrack: Identifiable, Hashable { + let id: Int32 + let name: String +} diff --git a/apps/ios/VMC/Data/SessionStore.swift b/apps/ios/VMC/Data/SessionStore.swift new file mode 100644 index 0000000..c1b9533 --- /dev/null +++ b/apps/ios/VMC/Data/SessionStore.swift @@ -0,0 +1,68 @@ +import Foundation + +@MainActor +final class SessionStore: ObservableObject { + static let defaultApiBase = "https://master.vonas.nl" + + @Published var refreshToken: String? + @Published var apiBase: String + @Published var profileId: String? + @Published var profileName: String? + @Published var profileAvatarId: String? + + private let defaults = UserDefaults.standard + private enum Key { + static let token = "vmc.refresh_token" + static let api = "vmc.api_base" + static let profileId = "vmc.profile_id" + static let profileName = "vmc.profile_name" + static let profileAvatar = "vmc.profile_avatar" + } + + init() { + refreshToken = defaults.string(forKey: Key.token) + apiBase = defaults.string(forKey: Key.api) ?? Self.defaultApiBase + profileId = defaults.string(forKey: Key.profileId) + profileName = defaults.string(forKey: Key.profileName) + profileAvatarId = defaults.string(forKey: Key.profileAvatar) + } + + var isAuthed: Bool { !(refreshToken ?? "").isEmpty } + var hasProfile: Bool { !(profileId ?? "").isEmpty } + + func setToken(_ token: String?) { + refreshToken = token + if let token, !token.isEmpty { + defaults.set(token, forKey: Key.token) + } else { + defaults.removeObject(forKey: Key.token) + setProfile(id: nil, name: nil, avatarId: nil) + } + } + + func setApiBase(_ url: String) { + let clean = url.trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "/")) + apiBase = clean.isEmpty ? Self.defaultApiBase : clean + defaults.set(apiBase, forKey: Key.api) + } + + func setProfile(id: String?, name: String?, avatarId: String?) { + profileId = id + profileName = name + profileAvatarId = avatarId + if let id, !id.isEmpty { + defaults.set(id, forKey: Key.profileId) + if let name, !name.isEmpty { defaults.set(name, forKey: Key.profileName) } + else { defaults.removeObject(forKey: Key.profileName) } + if let avatarId, !avatarId.isEmpty { defaults.set(avatarId, forKey: Key.profileAvatar) } + else { defaults.removeObject(forKey: Key.profileAvatar) } + } else { + defaults.removeObject(forKey: Key.profileId) + defaults.removeObject(forKey: Key.profileName) + defaults.removeObject(forKey: Key.profileAvatar) + profileName = nil + profileAvatarId = nil + } + } +} diff --git a/apps/ios/VMC/Info.plist b/apps/ios/VMC/Info.plist new file mode 100644 index 0000000..c40d399 --- /dev/null +++ b/apps/ios/VMC/Info.plist @@ -0,0 +1,60 @@ + + + + + CFBundleDevelopmentRegion + nl + CFBundleDisplayName + VMC + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSRequiresIPhoneOS + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSAllowsArbitraryLoadsForMedia + + NSAllowsLocalNetworking + + + NSLocalNetworkUsageDescription + VMC speelt media af vanaf je Media Cluster op het lokale netwerk. + NSBonjourServices + + _http._tcp + _https._tcp + + UILaunchScreen + + UIColorName + AccentColor + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/apps/ios/VMC/Playback/PlayerScreen.swift b/apps/ios/VMC/Playback/PlayerScreen.swift new file mode 100644 index 0000000..2025341 --- /dev/null +++ b/apps/ios/VMC/Playback/PlayerScreen.swift @@ -0,0 +1,481 @@ +import SwiftUI + +struct PlayRequest: Identifiable { + let id = UUID() + let url: URL + let title: String + let mediaType: String + let mediaFileId: String? + let movieId: String? + let episodeId: String? + let seriesId: String? + let startMs: Int64 + let isLive: Bool + let fallbackUrl: URL? + + static func vod( + url: URL, + title: String, + mediaType: String, + mediaFileId: String, + movieId: String?, + episodeId: String?, + seriesId: String?, + startMs: Int64 + ) -> PlayRequest { + PlayRequest( + url: url, title: title, mediaType: mediaType, mediaFileId: mediaFileId, + movieId: movieId, episodeId: episodeId, seriesId: seriesId, + startMs: startMs, isLive: false, fallbackUrl: nil + ) + } + + static func live(url: URL, title: String, fallback: URL?) -> PlayRequest { + PlayRequest( + url: url, title: title, mediaType: "live", mediaFileId: nil, + movieId: nil, episodeId: nil, seriesId: nil, + startMs: 0, isLive: true, fallbackUrl: fallback + ) + } +} + +struct PlayerScreen: View { + @EnvironmentObject private var app: AppModel + @Environment(\.dismiss) private var dismiss + + let request: PlayRequest + + @StateObject private var player = VLCPlayerController() + @State private var controlsVisible = true + @State private var loading = true + @State private var error: String? + @State private var progressTask: Task? + @State private var hideTask: Task? + @State private var showTracks = false + @State private var trackTab = 0 + @State private var subtitleHits: [SubtitleHit] = [] + @State private var subsLoading = false + @State private var subsError: String? + + var body: some View { + ZStack { + Color.black.ignoresSafeArea() + VLCVideoView(controller: player) + .ignoresSafeArea() + .allowsHitTesting(false) + + // VLC eet touches op — vang tikken altijd zelf af. + Color.clear + .contentShape(Rectangle()) + .ignoresSafeArea() + .onTapGesture { toggleControls() } + .allowsHitTesting(!controlsVisible) + + if loading && !player.isPlaying { + ProgressView(player.stateText) + .tint(.white) + .foregroundStyle(.white) + .allowsHitTesting(false) + } + + if let error { + errorOverlay(error) + } else if player.hasError { + errorOverlay(player.errorMessage ?? "Afspeelfout") + } + + if controlsVisible { + controlsOverlay + .transition(.opacity) + } + } + .statusBarHidden(true) + .persistentSystemOverlays(.hidden) + .onAppear { app.enterPlayback() } + .task { + await start() + scheduleHide() + } + .onDisappear { + app.leavePlayback() + hideTask?.cancel() + progressTask?.cancel() + Task { await saveProgress() } + player.stop() + } + .sheet(isPresented: $showTracks) { + tracksSheet + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } + } + + private var controlsOverlay: some View { + VStack(spacing: 0) { + HStack { + Button { + dismiss() + } label: { + Image(systemName: "chevron.down") + .font(.title3.weight(.semibold)) + .foregroundStyle(.white) + .padding(12) + .background(.black.opacity(0.4), in: Circle()) + } + VStack(alignment: .leading, spacing: 2) { + Text(request.title) + .font(McFonts.headline) + .foregroundStyle(.white) + .lineLimit(1) + Text(player.stateText) + .font(McFonts.caption) + .foregroundStyle(.white.opacity(0.7)) + } + Spacer() + if !request.isLive { + Button { + showTracks = true + scheduleHide(cancelOnly: true) + Task { await loadSubtitles() } + } label: { + Image(systemName: "captions.bubble.fill") + .font(.title3) + .foregroundStyle(.white) + .padding(12) + .background(.black.opacity(0.4), in: Circle()) + } + } + } + .padding(.horizontal, 16) + .padding(.top, 12) + + Spacer() + .contentShape(Rectangle()) + .onTapGesture { hideControls() } + + VStack(spacing: 14) { + if !request.isLive { + Slider( + value: Binding( + get: { Double(player.position) }, + set: { + player.seek(ratio: Float($0)) + scheduleHide() + } + ), + in: 0...1 + ) + .tint(McColors.accent) + + HStack { + Text(player.timeText).font(McFonts.caption).foregroundStyle(.white.opacity(0.85)) + Spacer() + Text(player.lengthText).font(McFonts.caption).foregroundStyle(.white.opacity(0.85)) + } + } + + HStack(spacing: 36) { + if !request.isLive { + Button { + player.seek(toMs: max(player.currentPositionMs() - 10_000, 0)) + scheduleHide() + } label: { + Image(systemName: "gobackward.10").font(.title).foregroundStyle(.white) + } + } + Button { + player.togglePlay() + scheduleHide() + } label: { + Image(systemName: player.isPlaying ? "pause.circle.fill" : "play.circle.fill") + .font(.system(size: 64)) + .foregroundStyle(.white) + .shadow(radius: 8) + } + if !request.isLive { + Button { + player.seek(toMs: player.currentPositionMs() + 10_000) + scheduleHide() + } label: { + Image(systemName: "goforward.10").font(.title).foregroundStyle(.white) + } + } + } + } + .padding(20) + .background( + LinearGradient(colors: [.clear, .black.opacity(0.8)], startPoint: .top, endPoint: .bottom) + ) + } + } + + private var tracksSheet: some View { + NavigationStack { + VStack(spacing: 0) { + Picker("", selection: $trackTab) { + Text("In bestand").tag(0) + Text("Downloaden").tag(1) + Text("Audio").tag(2) + } + .pickerStyle(.segmented) + .padding() + + if trackTab == 0 { + List { + ForEach(player.subtitleTracks) { track in + Button { + player.selectSubtitle(id: track.id) + showTracks = false + scheduleHide() + } label: { + HStack { + Text(track.name).foregroundStyle(McColors.text) + Spacer() + if track.id == player.currentSubtitleId { + Image(systemName: "checkmark").foregroundStyle(McColors.accent) + } + } + } + .listRowBackground(McColors.bgElevated) + } + } + .scrollContentBackground(.hidden) + } else if trackTab == 1 { + Group { + if subsLoading { + ProgressView("Zoeken…").padding(.top, 40) + } else if let subsError { + Text(subsError).foregroundStyle(McColors.danger).padding() + } else if subtitleHits.isEmpty { + ContentUnavailableView("Geen downloads", systemImage: "arrow.down.circle", description: Text("Geen OpenSubtitles-resultaten.")) + } else { + List(subtitleHits) { hit in + Button { + Task { await applySubtitle(hit) } + } label: { + VStack(alignment: .leading, spacing: 4) { + Text(hit.release ?? hit.fileName ?? "Ondertitel") + .foregroundStyle(McColors.text) + .lineLimit(2) + HStack(spacing: 8) { + Text((hit.language ?? "?").uppercased()) + if hit.hearingImpaired == true { Text("SDH") } + if let n = hit.downloadCount { Text("↓\(n)") } + } + .font(McFonts.caption) + .foregroundStyle(McColors.textDim) + } + } + .listRowBackground(McColors.bgElevated) + } + .scrollContentBackground(.hidden) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List { + ForEach(player.audioTracks) { track in + Button { + player.selectAudio(id: track.id) + showTracks = false + scheduleHide() + } label: { + HStack { + Text(track.name).foregroundStyle(McColors.text) + Spacer() + if track.id == player.currentAudioId { + Image(systemName: "checkmark").foregroundStyle(McColors.accent) + } + } + } + .listRowBackground(McColors.bgElevated) + } + } + .scrollContentBackground(.hidden) + } + } + .background(McColors.bg) + .navigationTitle("Sporen") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Sluiten") { showTracks = false } + } + } + .onAppear { player.refreshTracks() } + } + } + + private func errorOverlay(_ message: String) -> some View { + VStack(spacing: 14) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.largeTitle) + .foregroundStyle(McColors.accent) + Text(message) + .multilineTextAlignment(.center) + .foregroundStyle(.white) + .padding(.horizontal) + Button("Opnieuw") { Task { await start() } } + .buttonStyle(McPrimaryButtonStyle()) + Button("Sluiten") { dismiss() } + .buttonStyle(McSecondaryButtonStyle()) + } + .padding(24) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 20)) + .padding(24) + } + + private func toggleControls() { + if controlsVisible { + hideControls() + } else { + showControls() + } + } + + private func showControls() { + withAnimation(.easeOut(duration: 0.2)) { controlsVisible = true } + scheduleHide() + } + + private func hideControls() { + hideTask?.cancel() + withAnimation(.easeOut(duration: 0.2)) { controlsVisible = false } + } + + private func scheduleHide(cancelOnly: Bool = false) { + hideTask?.cancel() + guard !cancelOnly else { return } + hideTask = Task { + try? await Task.sleep(nanoseconds: 3_000_000_000) + guard !Task.isCancelled else { return } + await MainActor.run { + withAnimation(.easeOut(duration: 0.25)) { controlsVisible = false } + } + } + } + + private func start() async { + loading = true + error = nil + if request.isLive { + player.playLive(url: request.url, fallback: request.fallbackUrl) + } else { + player.play(url: request.url, startMs: request.startMs) + progressTask = Task { await progressLoop() } + } + try? await Task.sleep(nanoseconds: 900_000_000) + loading = false + player.refreshTracks() + } + + private func progressLoop() async { + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 15_000_000_000) + await saveProgress() + } + } + + private func saveProgress() async { + guard !request.isLive else { return } + let pos = player.currentPositionMs() + let dur = player.durationMs() + guard pos > 0 else { return } + try? await app.api.postProgress( + mediaType: request.mediaType, + movieId: request.movieId, + episodeId: request.episodeId, + seriesId: request.seriesId, + positionMs: pos, + durationMs: max(dur, 0) + ) + } + + private func loadSubtitles() async { + guard let mediaFileId = request.mediaFileId else { return } + subsLoading = true + subsError = nil + do { + let result = try await app.api.searchSubtitles(mediaFileId: mediaFileId) + subtitleHits = result.results + if result.configured == false { + subsError = "OpenSubtitles is niet geconfigureerd op de master." + } + subsLoading = false + } catch { + subsError = error.localizedDescription + subsLoading = false + } + } + + private func applySubtitle(_ hit: SubtitleHit) async { + do { + let fetched = try await app.api.fetchSubtitle(fileId: hit.fileId, source: hit.source ?? "org") + guard let data = Data(base64Encoded: fetched.contentBase64) else { + throw ApiError.message("Ongeldige ondertiteldata") + } + let ext = (fetched.format?.isEmpty == false ? fetched.format! : "srt") + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("vmc-sub-\(hit.fileId).\(ext)") + try data.write(to: url, options: .atomic) + player.addExternalSubtitle(url: url) + showTracks = false + scheduleHide() + } catch { + subsError = error.localizedDescription + } + } +} + +struct PlayLauncher: View { + @EnvironmentObject private var app: AppModel + let mediaFileId: String + let title: String + let mediaType: String + let movieId: String? + let episodeId: String? + let seriesId: String? + let startMs: Int64 + + @State private var request: PlayRequest? + @State private var error: String? + @State private var loading = true + + var body: some View { + ZStack { + Color.black.ignoresSafeArea() + if let request { + PlayerScreen(request: request) + } else if loading { + ProgressView("Stream voorbereiden…").tint(.white).foregroundStyle(.white) + } else if let error { + VStack(spacing: 12) { + Text(error).foregroundStyle(McColors.danger).multilineTextAlignment(.center).padding() + Button("Opnieuw") { Task { await prepare() } }.buttonStyle(McPrimaryButtonStyle()) + } + } + } + .task { await prepare() } + .onAppear { app.enterPlayback() } + .onDisappear { app.leavePlayback() } + } + + private func prepare() async { + loading = true + error = nil + do { + let play = try await app.api.play(mediaFileId: mediaFileId) + guard let url = URL(string: play.streamUrl) else { + throw ApiError.message("Ongeldige stream-URL") + } + request = .vod( + url: url, title: title, mediaType: mediaType, mediaFileId: mediaFileId, + movieId: movieId, episodeId: episodeId, seriesId: seriesId, startMs: startMs + ) + loading = false + } catch { + self.error = error.localizedDescription + loading = false + } + } +} diff --git a/apps/ios/VMC/Playback/VLCPlayerController.swift b/apps/ios/VMC/Playback/VLCPlayerController.swift new file mode 100644 index 0000000..4b196fc --- /dev/null +++ b/apps/ios/VMC/Playback/VLCPlayerController.swift @@ -0,0 +1,261 @@ +import Combine +import MobileVLCKit +import SwiftUI +import UIKit + +final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDelegate { + @Published var isPlaying = false + @Published var position: Float = 0 + @Published var timeText = "0:00" + @Published var lengthText = "0:00" + @Published var stateText = "Laden…" + @Published var hasError = false + @Published var errorMessage: String? + @Published var subtitleTracks: [PlayerTrack] = [] + @Published var audioTracks: [PlayerTrack] = [] + @Published var currentSubtitleId: Int32 = -1 + @Published var currentAudioId: Int32 = -1 + + let mediaPlayer = VLCMediaPlayer() + private var lengthMs: Int32 = 0 + private var tick: AnyCancellable? + + override init() { + super.init() + mediaPlayer.delegate = self + } + + func attach(to view: UIView) { + mediaPlayer.drawable = view + } + + func play(url: URL, startMs: Int64 = 0, subtitleFileURL: URL? = nil) { + hasError = false + errorMessage = nil + stateText = "Verbinden…" + subtitleTracks = [] + audioTracks = [] + let media = VLCMedia(url: url) + media.addOption(":network-caching=2500") + media.addOption(":http-reconnect") + media.addOption(":avcodec-hw=any") + media.addOption(":sout-mux-caching=1500") + media.addOption(":sub-track=0") + // VLC: hoger = kleiner (hoogte ≈ scherm/N). Default ~16 is te groot op iPhone. + media.addOption(":freetype-rel-fontsize=\(Self.subtitleRelFontSize)") + media.addOption(":freetype-bold=1") + mediaPlayer.media = media + mediaPlayer.play() + applySubtitleStyle() + if startMs > 2000 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { [weak self] in + self?.seek(toMs: startMs) + } + } + if let subtitleFileURL { + DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { [weak self] in + self?.addExternalSubtitle(url: subtitleFileURL) + } + } + startTicker() + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in + self?.refreshTracks() + self?.applySubtitleStyle() + } + } + + func playLive(url: URL, fallback: URL? = nil) { + hasError = false + errorMessage = nil + stateText = "Live laden…" + let media = VLCMedia(url: url) + media.addOption(":network-caching=1200") + media.addOption(":http-reconnect") + media.addOption(":live-caching=1000") + media.addOption(":freetype-rel-fontsize=\(Self.subtitleRelFontSize)") + mediaPlayer.media = media + mediaPlayer.play() + applySubtitleStyle() + if let fallback { + DispatchQueue.main.asyncAfter(deadline: .now() + 8) { [weak self] in + guard let self, !self.isPlaying || self.hasError else { return } + let fb = VLCMedia(url: fallback) + fb.addOption(":network-caching=1200") + fb.addOption(":http-reconnect") + fb.addOption(":freetype-rel-fontsize=\(Self.subtitleRelFontSize)") + self.mediaPlayer.media = fb + self.mediaPlayer.play() + self.applySubtitleStyle() + } + } + startTicker() + } + + func togglePlay() { + if mediaPlayer.isPlaying { mediaPlayer.pause() } + else { mediaPlayer.play() } + } + + func seek(toMs: Int64) { + mediaPlayer.time = VLCTime(number: NSNumber(value: toMs)) + } + + func seek(ratio: Float) { + mediaPlayer.position = ratio + } + + func currentPositionMs() -> Int64 { Int64(mediaPlayer.time.intValue) } + + func durationMs() -> Int64 { + let v = mediaPlayer.media?.length.intValue ?? lengthMs + return Int64(max(v, 0)) + } + + func selectSubtitle(id: Int32) { + mediaPlayer.currentVideoSubTitleIndex = id + currentSubtitleId = id + } + + func selectAudio(id: Int32) { + mediaPlayer.currentAudioTrackIndex = id + currentAudioId = id + } + + func addExternalSubtitle(url: URL) { + mediaPlayer.addPlaybackSlave(url, type: .subtitle, enforce: true) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in + self?.refreshTracks() + self?.applySubtitleStyle() + } + } + + /// VLC relative fontsize: groter getal = kleinere tekst (~1/N van schermhoogte). + private static let subtitleRelFontSize = 32 + + private func applySubtitleStyle() { + let size = NSNumber(value: Self.subtitleRelFontSize) + let sel = NSSelectorFromString("setTextRendererFontSize:") + if mediaPlayer.responds(to: sel) { + mediaPlayer.perform(sel, with: size) + } + } + + func refreshTracks() { + var subs: [PlayerTrack] = [PlayerTrack(id: -1, name: "Uit")] + if let names = mediaPlayer.videoSubTitlesNames as? [Any], + let indexes = mediaPlayer.videoSubTitlesIndexes as? [Any] { + for (i, raw) in indexes.enumerated() { + let idx = (raw as? NSNumber)?.int32Value ?? Int32(truncating: raw as? NSNumber ?? -1) + if idx < 0 { continue } + let label: String + if i < names.count, let n = names[i] as? String, !n.isEmpty { + label = n + } else { + label = "Ondertitel \(subs.count)" + } + // Skip junk mime-like labels + if label.uppercased().contains("TEXT/") || label.uppercased().hasPrefix("HTTP") { + subs.append(PlayerTrack(id: idx, name: "Ondertitel \(subs.count)")) + } else { + subs.append(PlayerTrack(id: idx, name: label)) + } + } + } + subtitleTracks = subs + currentSubtitleId = mediaPlayer.currentVideoSubTitleIndex + + var audios: [PlayerTrack] = [] + if let names = mediaPlayer.audioTrackNames as? [Any], + let indexes = mediaPlayer.audioTrackIndexes as? [Any] { + for (i, raw) in indexes.enumerated() { + let idx = (raw as? NSNumber)?.int32Value ?? -1 + if idx < 0 { continue } + let label: String + if i < names.count, let n = names[i] as? String, !n.isEmpty { + label = n + } else { + label = "Audio \(audios.count + 1)" + } + audios.append(PlayerTrack(id: idx, name: label)) + } + } + audioTracks = audios + currentAudioId = mediaPlayer.currentAudioTrackIndex + } + + func stop() { + tick?.cancel() + tick = nil + mediaPlayer.stop() + mediaPlayer.media = nil + } + + func mediaPlayerStateChanged(_ aNotification: Notification) { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.isPlaying = self.mediaPlayer.isPlaying + switch self.mediaPlayer.state { + case .buffering: self.stateText = "Bufferen…" + case .opening: self.stateText = "Openen…" + case .playing: + self.stateText = "Afspelen" + self.hasError = false + self.refreshTracks() + self.applySubtitleStyle() + case .paused: self.stateText = "Pauze" + case .stopped: self.stateText = "Gestopt" + case .ended: self.stateText = "Einde" + case .error: + self.hasError = true + self.errorMessage = "Kon dit bestand niet afspelen" + self.stateText = "Fout" + case .esAdded: + self.refreshTracks() + @unknown default: break + } + } + } + + private func startTicker() { + tick?.cancel() + tick = Timer.publish(every: 0.4, on: .main, in: .common) + .autoconnect() + .sink { [weak self] _ in + self?.refreshTime() + } + } + + private func refreshTime() { + let t = mediaPlayer.time.intValue + let l = mediaPlayer.media?.length.intValue ?? 0 + if l > 0 { lengthMs = l } + position = mediaPlayer.position + timeText = Self.format(ms: t) + lengthText = Self.format(ms: lengthMs) + isPlaying = mediaPlayer.isPlaying + } + + private static func format(ms: Int32) -> String { + let total = max(Int(ms) / 1000, 0) + let h = total / 3600 + let m = (total % 3600) / 60 + let s = total % 60 + if h > 0 { return String(format: "%d:%02d:%02d", h, m, s) } + return String(format: "%d:%02d", m, s) + } +} + +struct VLCVideoView: UIViewRepresentable { + let controller: VLCPlayerController + + func makeUIView(context: Context) -> UIView { + let view = UIView() + view.backgroundColor = .black + controller.attach(to: view) + return view + } + + func updateUIView(_ uiView: UIView, context: Context) { + controller.attach(to: uiView) + } +} diff --git a/apps/ios/VMC/Theme/McTheme.swift b/apps/ios/VMC/Theme/McTheme.swift new file mode 100644 index 0000000..8e4efae --- /dev/null +++ b/apps/ios/VMC/Theme/McTheme.swift @@ -0,0 +1,59 @@ +import SwiftUI + +enum McColors { + static let bg = Color(red: 0.035, green: 0.035, blue: 0.038) + static let bgElevated = Color(red: 0.075, green: 0.075, blue: 0.082) + static let surface = Color(red: 0.12, green: 0.12, blue: 0.13) + static let surfaceSoft = Color(red: 0.16, green: 0.16, blue: 0.175) + static let accent = Color(red: 0.898, green: 0.035, blue: 0.078) + static let accentSoft = Color(red: 0.898, green: 0.035, blue: 0.078).opacity(0.22) + static let text = Color(red: 0.96, green: 0.96, blue: 0.97) + static let textDim = Color(red: 0.62, green: 0.62, blue: 0.66) + static let danger = Color(red: 1.0, green: 0.42, blue: 0.42) + static let success = Color(red: 0.275, green: 0.827, blue: 0.412) + static let footer = Color(red: 0.05, green: 0.05, blue: 0.055).opacity(0.94) +} + +enum McFonts { + static let brand = Font.system(size: 40, weight: .black, design: .rounded) + static let title = Font.system(size: 26, weight: .bold, design: .rounded) + static let headline = Font.system(size: 17, weight: .semibold, design: .rounded) + static let body = Font.system(size: 15, weight: .regular, design: .rounded) + static let caption = Font.system(size: 12, weight: .medium, design: .rounded) +} + +struct McPrimaryButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(McFonts.headline) + .foregroundStyle(.white) + .padding(.horizontal, 22) + .padding(.vertical, 13) + .background( + LinearGradient( + colors: [ + McColors.accent, + McColors.accent.opacity(0.82), + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .opacity(configuration.isPressed ? 0.75 : 1), + in: Capsule() + ) + .shadow(color: McColors.accent.opacity(0.35), radius: configuration.isPressed ? 2 : 10, y: 4) + .scaleEffect(configuration.isPressed ? 0.98 : 1) + .animation(.easeOut(duration: 0.15), value: configuration.isPressed) + } +} + +struct McSecondaryButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(McFonts.headline) + .foregroundStyle(McColors.text) + .padding(.horizontal, 18) + .padding(.vertical, 12) + .background(McColors.surfaceSoft.opacity(configuration.isPressed ? 0.7 : 1), in: Capsule()) + } +} diff --git a/apps/ios/VMC/Theme/ProfileAvatars.swift b/apps/ios/VMC/Theme/ProfileAvatars.swift new file mode 100644 index 0000000..546297a --- /dev/null +++ b/apps/ios/VMC/Theme/ProfileAvatars.swift @@ -0,0 +1,20 @@ +import Foundation + +enum ProfileAvatars { + static let all: [(id: String, emoji: String, label: String)] = [ + ("fox", "🦊", "Vos"), + ("panda", "🐼", "Panda"), + ("robot", "🤖", "Robot"), + ("alien", "👽", "Alien"), + ("ninja", "🥷", "Ninja"), + ("pirate", "🏴‍☠️", "Piraat"), + ("wizard", "🧙", "Tovenaar"), + ("dino", "🦕", "Dino"), + ("cat", "🐱", "Kat"), + ("rocket", "🚀", "Raket"), + ] + + static func emoji(for id: String?) -> String { + all.first { $0.id == (id ?? "fox") }?.emoji ?? "🙂" + } +} diff --git a/apps/ios/VMC/UI/BrowseScreen.swift b/apps/ios/VMC/UI/BrowseScreen.swift new file mode 100644 index 0000000..999e2c1 --- /dev/null +++ b/apps/ios/VMC/UI/BrowseScreen.swift @@ -0,0 +1,193 @@ +import SwiftUI + +struct BrowseScreen: View { + @EnvironmentObject private var app: AppModel + let kind: String // "MOVIE" or "SERIES" + let title: String + + @State private var home: HomePayload? + @State private var shelfPrefs: [ShelfPref] = [] + @State private var mode: BrowseMode = .planks + @State private var items: [MediaItem] = [] + @State private var total = 0 + @State private var error: String? + @State private var loading = true + @State private var loadingMore = false + + private enum BrowseMode: Hashable { + case planks + case catalog(String) + } + + private var kindShelves: [ShelfRow] { + guard let home else { return [] } + return home.shelves.filter { shelf in + let k = (shelf.kind ?? "").uppercased() + if kind == "MOVIE" { return k == "MOVIE" || k.contains("MOVIE") } + return k == "SERIES" || k.contains("SERIES") + } + } + + private var chips: [(label: String, mode: BrowseMode)] { + var list: [(String, BrowseMode)] = [("Planken", .planks)] + if kind == "MOVIE" { + list += [("Alles", .catalog("movies")), ("Recent", .catalog("recent-movies")), ("4K", .catalog("4k-movies"))] + } else { + list += [("Alles", .catalog("series")), ("Recent", .catalog("recent-series"))] + } + for pref in shelfPrefs where pref.visible { + let k = (pref.kind ?? "").uppercased() + let match = kind == "MOVIE" ? (k == "MOVIE" || k.contains("MOVIE")) : (k == "SERIES" || k.contains("SERIES")) + if match { + let slug = pref.slug?.isEmpty == false ? pref.slug! : pref.shelfId + list.append((pref.name, .catalog(slug))) + } + } + return list + } + + private let cols = [GridItem(.adaptive(minimum: 104), spacing: 12)] + + var body: some View { + VStack(spacing: 0) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(Array(chips.enumerated()), id: \.offset) { _, chip in + Button { + mode = chip.mode + Task { await activate(chip.mode) } + } label: { + Text(chip.label) + .font(McFonts.caption) + .foregroundStyle(mode == chip.mode ? .white : McColors.textDim) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(mode == chip.mode ? McColors.accent : McColors.surface, in: Capsule()) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + + content + } + .background(McColors.bg.ignoresSafeArea()) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .principal) { + BrandHeader(subtitle: title.uppercased()) + } + } + .toolbarBackground(McColors.bg, for: .navigationBar) + .toolbarColorScheme(.dark, for: .navigationBar) + .task { await bootstrap() } + .refreshable { await bootstrap() } + } + + @ViewBuilder + private var content: some View { + if loading && home == nil && items.isEmpty { + ProgressView().tint(.white).frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error, home == nil && items.isEmpty { + VStack(spacing: 12) { + Text(error).foregroundStyle(McColors.danger) + Button("Opnieuw") { Task { await bootstrap() } }.buttonStyle(McPrimaryButtonStyle()) + } + } else { + switch mode { + case .planks: + ScrollView { + VStack(alignment: .leading, spacing: 22) { + if kindShelves.isEmpty { + ContentUnavailableView("Geen planken", systemImage: "rectangle.stack", description: Text("Nog geen \(title.lowercased())-planken zichtbaar.")) + .frame(maxWidth: .infinity) + .padding(.top, 40) + } else { + ForEach(kindShelves) { shelf in + if !shelf.items.isEmpty { + ShelfSection(title: shelf.title, items: shelf.items) + } + } + } + } + .padding(.bottom, 24) + } + case .catalog: + ScrollView { + LazyVGrid(columns: cols, spacing: 14) { + ForEach(items) { item in + NavigationLink(value: item) { + PosterCard(item: item, width: 104) + } + .buttonStyle(.plain) + .onAppear { + if item.id == items.last?.id { Task { await loadMore() } } + } + } + } + .padding(.horizontal, 16) + .padding(.bottom, 24) + if loadingMore { ProgressView().tint(.white).padding() } + } + .navigationDestination(for: MediaItem.self) { MediaRouter(item: $0) } + } + } + } + + private func bootstrap() async { + loading = true + error = nil + do { + async let homeTask = app.api.home() + async let prefsTask = app.api.getShelfPrefs() + home = try await homeTask + shelfPrefs = (try? await prefsTask)?.sorted(by: { $0.sortOrder < $1.sortOrder }) ?? [] + if case .catalog(let id) = mode { + await loadCatalog(id, reset: true) + } + loading = false + } catch { + self.error = error.localizedDescription + loading = false + } + } + + private func activate(_ mode: BrowseMode) async { + switch mode { + case .planks: + break + case .catalog(let id): + await loadCatalog(id, reset: true) + } + } + + private func loadCatalog(_ id: String, reset: Bool) async { + if reset { + items = [] + loading = true + } + do { + let page = try await app.api.browse(catalogId: id, skip: 0, take: 60) + total = page.total ?? page.items.count + items = page.items + loading = false + } catch { + self.error = error.localizedDescription + loading = false + } + } + + private func loadMore() async { + guard case .catalog(let id) = mode, !loadingMore, items.count < total else { return } + loadingMore = true + do { + let page = try await app.api.browse(catalogId: id, skip: items.count, take: 60) + items.append(contentsOf: page.items) + loadingMore = false + } catch { + loadingMore = false + } + } +} diff --git a/apps/ios/VMC/UI/Components.swift b/apps/ios/VMC/UI/Components.swift new file mode 100644 index 0000000..665290b --- /dev/null +++ b/apps/ios/VMC/UI/Components.swift @@ -0,0 +1,172 @@ +import SwiftUI + +struct RemoteImage: View { + let url: String? + var contentMode: ContentMode = .fill + /// Zet uit voor transparante logo's (geen donker vlak). + var opaqueBackground: Bool = true + + var body: some View { + Group { + if let url, let u = URL(string: url) { + AsyncImage(url: u) { phase in + switch phase { + case .success(let image): + image.resizable().aspectRatio(contentMode: contentMode) + case .failure: + if opaqueBackground { placeholder } else { Color.clear } + case .empty: + if opaqueBackground { + placeholder.overlay { ProgressView().tint(.white) } + } else { + Color.clear + } + @unknown default: + if opaqueBackground { placeholder } else { Color.clear } + } + } + } else if opaqueBackground { + placeholder + } else { + Color.clear + } + } + .clipped() + .background(opaqueBackground ? McColors.surface : Color.clear) + } + + private var placeholder: some View { + Rectangle().fill(McColors.surface) + } +} + +/// Title-card / logo zonder achtergrondvlak. +struct TitleLogo: View { + let url: String? + var maxWidth: CGFloat = 220 + var maxHeight: CGFloat = 56 + + var body: some View { + RemoteImage(url: url, contentMode: .fit, opaqueBackground: false) + .frame(maxWidth: maxWidth, maxHeight: maxHeight, alignment: .leading) + .shadow(color: .black.opacity(0.55), radius: 10, y: 4) + } +} + +struct PosterCard: View { + let item: MediaItem + var width: CGFloat = 112 + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + RemoteImage(url: item.posterUrl) + .frame(width: width, height: width * 1.5) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay(alignment: .topTrailing) { + if let badge = item.badge, !badge.isEmpty { + Text(badge) + .font(.system(size: 10, weight: .bold)) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(McColors.accent, in: Capsule()) + .foregroundStyle(.white) + .padding(6) + } + } + .overlay(alignment: .bottom) { + if let pos = item.positionMs, let dur = item.durationMs, dur > 0, pos > 0 { + GeometryReader { geo in + Capsule() + .fill(McColors.accent) + .frame(width: geo.size.width * min(CGFloat(pos) / CGFloat(dur), 1), height: 3) + .frame(maxHeight: .infinity, alignment: .bottom) + } + .frame(height: 3) + .padding(4) + } + } + Text(item.title) + .font(McFonts.caption) + .foregroundStyle(McColors.text) + .lineLimit(2) + .multilineTextAlignment(.leading) + .frame(width: width, height: 36, alignment: .topLeading) + } + .frame(width: width, alignment: .leading) + } +} + +struct MetaChip: View { + let text: String + var body: some View { + Text(text) + .font(McFonts.caption) + .foregroundStyle(McColors.textDim) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(McColors.surfaceSoft, in: Capsule()) + } +} + +struct BrandHeader: View { + var subtitle: String? = nil + + var body: some View { + HStack(spacing: 8) { + ZStack { + RoundedRectangle(cornerRadius: 7, style: .continuous) + .fill( + LinearGradient( + colors: [McColors.accent, McColors.accent.opacity(0.7)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 26, height: 26) + .shadow(color: McColors.accent.opacity(0.45), radius: 8, y: 2) + Text("V") + .font(.system(size: 14, weight: .black, design: .rounded)) + .foregroundStyle(.white) + } + VStack(alignment: .leading, spacing: 0) { + Text("VMC") + .font(.system(size: 18, weight: .black, design: .rounded)) + .foregroundStyle(McColors.text) + if let subtitle { + Text(subtitle) + .font(.system(size: 10, weight: .semibold, design: .rounded)) + .foregroundStyle(McColors.textDim) + } + } + } + } +} + +struct MediaRouter: View { + let item: MediaItem + + var body: some View { + switch item.type.lowercased() { + case "movie": + MovieDetailScreen(movieId: item.id) + case "series", "show": + SeriesDetailScreen(seriesId: item.id) + case "episode": + if let mediaFileId = item.mediaFileId { + PlayLauncher( + mediaFileId: mediaFileId, + title: item.title, + mediaType: "episode", + movieId: nil, + episodeId: item.id, + seriesId: item.seriesId, + startMs: item.positionMs ?? 0 + ) + } else { + ContentUnavailableView("Niet beschikbaar", systemImage: "play.slash") + } + default: + MovieDetailScreen(movieId: item.id) + } + } +} diff --git a/apps/ios/VMC/UI/DetailScreens.swift b/apps/ios/VMC/UI/DetailScreens.swift new file mode 100644 index 0000000..7c8841d --- /dev/null +++ b/apps/ios/VMC/UI/DetailScreens.swift @@ -0,0 +1,413 @@ +import SwiftUI +import UIKit + +struct MovieDetailScreen: View { + @EnvironmentObject private var app: AppModel + let movieId: String + + @State private var movie: MovieDetail? + @State private var error: String? + @State private var loading = true + @State private var playId: String? + + var body: some View { + Group { + if loading { + ProgressView().tint(.white) + } else if let error { + Text(error).foregroundStyle(McColors.danger).padding() + } else if let movie { + GeometryReader { geo in + ScrollView { + VStack(alignment: .leading, spacing: 0) { + DetailHero( + backdrop: movie.backdropUrl ?? movie.posterUrl, + logo: movie.logoUrl, + title: movie.title, + width: geo.size.width + ) + + VStack(alignment: .leading, spacing: 16) { + metaRow(year: movie.year, runtime: movie.runtime, rating: movie.rating, genres: movie.genres) + + HStack(spacing: 12) { + if let mediaFileId = movie.preferredMediaFileId { + Button { playId = mediaFileId } label: { + Label("Afspelen", systemImage: "play.fill") + .frame(maxWidth: .infinity) + } + .buttonStyle(McPrimaryButtonStyle()) + } + BookmarkToggleButton( + isOn: app.favorites.contains(type: "movie", id: movie.id) + ) { + Task { + _ = await app.favorites.toggle( + type: "movie", + id: movie.id, + title: movie.title, + year: movie.year, + posterUrl: movie.posterUrl, + backdropUrl: movie.backdropUrl, + logoUrl: movie.logoUrl, + api: app.api + ) + } + } + } + + if let overview = movie.overview, !overview.isEmpty { + Text(overview) + .font(McFonts.body) + .foregroundStyle(McColors.textDim) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + + peopleSection(cast: movie.cast, director: movie.director) + + if let files = movie.mediaFiles, !files.isEmpty { + Text("Versies") + .font(McFonts.headline) + .foregroundStyle(McColors.text) + ForEach(files) { file in + versionRow(file) { playId = file.id } + } + } + } + .padding(.horizontal, 16) + .padding(.top, 8) + .padding(.bottom, 28) + .frame(width: geo.size.width, alignment: .leading) + } + } + } + .fullScreenCover(item: Binding( + get: { playId.map { IdentifiedString(id: $0) } }, + set: { playId = $0?.id } + )) { item in + PlayLauncher( + mediaFileId: item.id, + title: movie.title, + mediaType: "movie", + movieId: movie.id, + episodeId: nil, + seriesId: nil, + startMs: 0 + ) + .environmentObject(app) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(McColors.bg.ignoresSafeArea()) + .navigationBarTitleDisplayMode(.inline) + .toolbarBackground(.hidden, for: .navigationBar) + .task { await load() } + } + + private func load() async { + loading = true + do { + movie = try await app.api.movie(id: movieId) + loading = false + } catch { + self.error = error.localizedDescription + loading = false + } + } +} + +struct SeriesDetailScreen: View { + @EnvironmentObject private var app: AppModel + let seriesId: String + + @State private var series: SeriesDetail? + @State private var error: String? + @State private var loading = true + @State private var seasonIndex = 0 + @State private var playTarget: EpisodePlay? + + var body: some View { + Group { + if loading { + ProgressView().tint(.white) + } else if let error { + Text(error).foregroundStyle(McColors.danger).padding() + } else if let series { + GeometryReader { geo in + ScrollView { + VStack(alignment: .leading, spacing: 0) { + DetailHero( + backdrop: series.backdropUrl ?? series.posterUrl, + logo: series.logoUrl, + title: series.title, + width: geo.size.width + ) + + VStack(alignment: .leading, spacing: 16) { + metaRow(year: series.year, runtime: nil, rating: series.rating, genres: series.genres) + + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + Task { + _ = await app.favorites.toggle( + type: "series", + id: series.id, + title: series.title, + year: series.year, + posterUrl: series.posterUrl, + backdropUrl: series.backdropUrl, + logoUrl: series.logoUrl, + api: app.api + ) + } + } label: { + Label( + app.favorites.contains(type: "series", id: series.id) + ? "In favorieten" + : "Bewaar in favorieten", + systemImage: app.favorites.contains(type: "series", id: series.id) + ? "bookmark.fill" + : "bookmark" + ) + .frame(maxWidth: .infinity) + } + .buttonStyle(McSecondaryButtonStyle()) + + if let overview = series.overview, !overview.isEmpty { + Text(overview) + .font(McFonts.body) + .foregroundStyle(McColors.textDim) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + + peopleSection(cast: series.cast, director: series.director) + + if let seasons = series.seasons, !seasons.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(Array(seasons.enumerated()), id: \.offset) { idx, season in + Button { + withAnimation(.easeOut(duration: 0.2)) { seasonIndex = idx } + } label: { + Text("S\(season.seasonNumber)") + .font(McFonts.caption) + .foregroundStyle(seasonIndex == idx ? .white : McColors.textDim) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(seasonIndex == idx ? McColors.accent : McColors.surface, in: Capsule()) + } + .buttonStyle(.plain) + } + } + } + + let episodes = seasons[safe: seasonIndex]?.episodes ?? [] + LazyVStack(spacing: 12) { + ForEach(episodes) { ep in + episodeCard(ep, seriesTitle: series.title, width: geo.size.width - 32) + } + } + } + } + .padding(.horizontal, 16) + .padding(.top, 8) + .padding(.bottom, 28) + .frame(width: geo.size.width, alignment: .leading) + } + } + } + .fullScreenCover(item: $playTarget) { target in + PlayLauncher( + mediaFileId: target.mediaFileId, + title: target.title, + mediaType: "episode", + movieId: nil, + episodeId: target.episodeId, + seriesId: seriesId, + startMs: 0 + ) + .environmentObject(app) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(McColors.bg.ignoresSafeArea()) + .navigationBarTitleDisplayMode(.inline) + .task { await load() } + } + + @ViewBuilder + private func episodeCard(_ ep: Episode, seriesTitle: String, width: CGFloat) -> some View { + let title = (ep.title?.isEmpty == false) ? ep.title! : "Aflevering \(ep.episodeNumber ?? 0)" + let playable = ep.mediaFileId.map { !$0.isEmpty } ?? false + + Button { + guard let mediaFileId = ep.mediaFileId, playable else { return } + playTarget = EpisodePlay( + mediaFileId: mediaFileId, + episodeId: ep.id, + title: "\(seriesTitle) · \(title)" + ) + } label: { + HStack(alignment: .top, spacing: 12) { + ZStack(alignment: .bottomTrailing) { + RemoteImage(url: ep.stillUrl ?? series?.backdropUrl) + .frame(width: 128, height: 72) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + if playable { + Image(systemName: "play.circle.fill") + .foregroundStyle(.white) + .shadow(radius: 4) + .padding(6) + } + } + + VStack(alignment: .leading, spacing: 4) { + Text("\(ep.episodeNumber ?? 0). \(title)") + .font(McFonts.headline) + .foregroundStyle(playable ? McColors.text : McColors.textDim) + .lineLimit(2) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + if let runtime = ep.runtime { + Text("\(runtime) min") + .font(McFonts.caption) + .foregroundStyle(McColors.textDim) + } + if let overview = ep.overview, !overview.isEmpty { + Text(overview) + .font(McFonts.caption) + .foregroundStyle(McColors.textDim) + .lineLimit(3) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + .padding(10) + .frame(width: width, alignment: .leading) + .background(McColors.bgElevated, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + .buttonStyle(.plain) + .disabled(!playable) + } + + private func load() async { + loading = true + do { + series = try await app.api.series(id: seriesId) + loading = false + } catch { + self.error = error.localizedDescription + loading = false + } + } +} + +struct DetailHero: View { + let backdrop: String? + let logo: String? + let title: String + let width: CGFloat + + var body: some View { + ZStack(alignment: .bottomLeading) { + RemoteImage(url: backdrop) + .frame(width: width, height: 300) + .clipped() + + LinearGradient( + colors: [.clear, .black.opacity(0.2), McColors.bg.opacity(0.95), McColors.bg], + startPoint: .top, + endPoint: .bottom + ) + .frame(width: width, height: 300) + + VStack(alignment: .leading, spacing: 10) { + if let logo, !logo.isEmpty { + TitleLogo(url: logo, maxWidth: min(width - 40, 260), maxHeight: 64) + } else { + Text(title) + .font(.system(size: 28, weight: .black, design: .rounded)) + .foregroundStyle(.white) + .shadow(color: .black.opacity(0.5), radius: 8, y: 3) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: width - 32, alignment: .leading) + } + } + .padding(.horizontal, 16) + .padding(.bottom, 18) + } + .frame(width: width, height: 300) + .clipped() + } +} + +@ViewBuilder +private func metaRow(year: Int?, runtime: Int?, rating: String?, genres: [String]?) -> some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + if let year { MetaChip(text: "\(year)") } + if let runtime { MetaChip(text: "\(runtime) min") } + if let rating { MetaChip(text: "★ \(rating)") } + ForEach(genres ?? [], id: \.self) { MetaChip(text: $0) } + } + } +} + +@ViewBuilder +private func peopleSection(cast: [String]?, director: [String]?) -> some View { + if let director, !director.isEmpty { + VStack(alignment: .leading, spacing: 4) { + Text("Regie").font(McFonts.caption).foregroundStyle(McColors.textDim) + Text(director.joined(separator: ", ")) + .font(McFonts.body) + .foregroundStyle(McColors.text) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + if let cast, !cast.isEmpty { + VStack(alignment: .leading, spacing: 4) { + Text("Cast").font(McFonts.caption).foregroundStyle(McColors.textDim) + Text(cast.prefix(8).joined(separator: ", ")) + .font(McFonts.body) + .foregroundStyle(McColors.text) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} + +@ViewBuilder +private func versionRow(_ file: MediaFileInfo, onPlay: @escaping () -> Void) -> some View { + Button(action: onPlay) { + HStack(spacing: 12) { + QualityBadgeRow(file: file) + Image(systemName: "play.circle.fill") + .font(.title2) + .foregroundStyle(McColors.accent) + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .background(McColors.surface, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + .buttonStyle(.plain) +} + +private struct IdentifiedString: Identifiable { let id: String } + +private struct EpisodePlay: Identifiable { + let id = UUID() + let mediaFileId: String + let episodeId: String + let title: String +} + +private extension Array { + subscript(safe index: Int) -> Element? { + indices.contains(index) ? self[index] : nil + } +} diff --git a/apps/ios/VMC/UI/HomeScreen.swift b/apps/ios/VMC/UI/HomeScreen.swift new file mode 100644 index 0000000..d3f12ab --- /dev/null +++ b/apps/ios/VMC/UI/HomeScreen.swift @@ -0,0 +1,251 @@ +import SwiftUI + +struct HomeScreen: View { + @EnvironmentObject private var app: AppModel + @State private var home: HomePayload? + @State private var error: String? + @State private var loading = true + + private var heroItems: [MediaItem] { + guard let home else { return [] } + var list = home.spotlight ?? [] + if list.isEmpty { list = Array(home.continueWatching.prefix(6)) } + if list.isEmpty { + list = Array(home.shelves.flatMap(\.items).prefix(8)) + } + var seen = Set() + return list.filter { seen.insert($0.id).inserted } + } + + var body: some View { + Group { + if loading && home == nil { + ProgressView().tint(.white).frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error, home == nil { + VStack(spacing: 12) { + Text(error).foregroundStyle(McColors.danger).multilineTextAlignment(.center) + Button("Opnieuw") { Task { await load() } }.buttonStyle(McPrimaryButtonStyle()) + } + .padding() + } else if let home { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + if !heroItems.isEmpty { + HeroCarousel(items: heroItems) + } + + VStack(alignment: .leading, spacing: 22) { + if !app.favorites.mediaItems.isEmpty { + ShelfSection(title: "Favorieten", items: app.favorites.mediaItems) + } + + if !home.continueWatching.isEmpty { + ShelfSection(title: "Verder kijken", items: home.continueWatching) + } + + ForEach(home.shelves) { shelf in + if !shelf.items.isEmpty { + ShelfSection(title: shelf.title, items: shelf.items) + } + } + } + .padding(.top, 18) + .padding(.bottom, 28) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + .background(McColors.bg.ignoresSafeArea()) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .principal) { + BrandHeader(subtitle: "MEDIA CLUSTER") + } + ToolbarItem(placement: .topBarTrailing) { + Button { app.tab = .account } label: { + Text(ProfileAvatars.emoji(for: app.store.profileAvatarId)) + .font(.title3) + } + } + } + .toolbarBackground(.hidden, for: .navigationBar) + .toolbarColorScheme(.dark, for: .navigationBar) + .task { await load() } + .refreshable { await load() } + } + + private func load() async { + loading = home == nil + error = nil + do { + home = try await app.api.home() + if let favs = home?.favorites { + app.favorites.replaceAll(favs) + } else { + await app.favorites.refresh(using: app.api) + } + loading = false + } catch { + self.error = error.localizedDescription + loading = false + } + } +} + +struct HeroCarousel: View { + let items: [MediaItem] + @State private var index = 0 + @State private var pulse = false + + private let heroHeight: CGFloat = 300 + + var body: some View { + ZStack(alignment: .bottom) { + TabView(selection: $index) { + ForEach(Array(items.enumerated()), id: \.element.id) { i, item in + NavigationLink(value: item) { + HeroSlide(item: item, active: i == index, pulse: pulse) + } + .buttonStyle(.plain) + .tag(i) + } + } + .tabViewStyle(.page(indexDisplayMode: .never)) + .frame(maxWidth: .infinity) + .frame(height: heroHeight) + + VStack(spacing: 0) { + Spacer(minLength: 0) + LinearGradient( + colors: [.clear, McColors.bg], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 64) + .allowsHitTesting(false) + + if items.count > 1 { + HStack(spacing: 6) { + ForEach(items.indices, id: \.self) { i in + Capsule() + .fill(i == index ? Color.white : Color.white.opacity(0.35)) + .frame(width: i == index ? 18 : 6, height: 5) + .animation(.spring(response: 0.35), value: index) + } + } + .padding(.bottom, 8) + } + } + .frame(height: heroHeight) + .allowsHitTesting(false) + } + .frame(maxWidth: .infinity) + .frame(height: heroHeight) + .navigationDestination(for: MediaItem.self) { MediaRouter(item: $0) } + .onAppear { + withAnimation(.easeInOut(duration: 10).repeatForever(autoreverses: true)) { + pulse = true + } + } + .task(id: items.count) { + guard items.count > 1 else { return } + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 5_500_000_000) + guard !Task.isCancelled else { break } + withAnimation(.easeInOut(duration: 0.55)) { + index = (index + 1) % items.count + } + } + } + } +} + +struct HeroSlide: View { + let item: MediaItem + let active: Bool + let pulse: Bool + + var body: some View { + GeometryReader { geo in + ZStack(alignment: .bottomLeading) { + RemoteImage(url: item.backdropUrl ?? item.posterUrl) + .frame(width: geo.size.width, height: geo.size.height) + .scaleEffect(active && pulse ? 1.05 : 1.0) + .clipped() + + LinearGradient( + colors: [ + .black.opacity(0.35), + .clear, + .black.opacity(0.15), + .black.opacity(0.78) + ], + startPoint: .top, + endPoint: .bottom + ) + + VStack(alignment: .leading, spacing: 10) { + Text(item.type.uppercased() == "SERIES" ? "SERIE" : "FILM") + .font(.system(size: 11, weight: .bold, design: .rounded)) + .tracking(1.6) + .foregroundStyle(McColors.accent) + + if let logo = item.logoUrl, !logo.isEmpty { + TitleLogo(url: logo, maxWidth: geo.size.width * 0.7, maxHeight: 56) + } else { + Text(item.title) + .font(.system(size: 28, weight: .black, design: .rounded)) + .foregroundStyle(.white) + .shadow(color: .black.opacity(0.55), radius: 8, y: 2) + .lineLimit(2) + } + + HStack(spacing: 10) { + Label("Bekijken", systemImage: "play.fill") + .font(.system(size: 13, weight: .bold, design: .rounded)) + .foregroundStyle(.black) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(.white, in: Capsule()) + if let year = item.year { + Text("\(year)") + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.85)) + } + } + } + .padding(.horizontal, 20) + .padding(.bottom, 36) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } +} + +struct ShelfSection: View { + let title: String + let items: [MediaItem] + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(title) + .font(McFonts.headline) + .foregroundStyle(McColors.text) + .padding(.horizontal, 16) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: 12) { + ForEach(items) { item in + NavigationLink(value: item) { + PosterCard(item: item) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 16) + } + } + .navigationDestination(for: MediaItem.self) { MediaRouter(item: $0) } + } +} diff --git a/apps/ios/VMC/UI/LinkScreen.swift b/apps/ios/VMC/UI/LinkScreen.swift new file mode 100644 index 0000000..ccd7d6d --- /dev/null +++ b/apps/ios/VMC/UI/LinkScreen.swift @@ -0,0 +1,116 @@ +import SwiftUI + +struct LinkScreen: View { + @EnvironmentObject private var app: AppModel + @State private var code: String? + @State private var linkUrl = "" + @State private var status = "Verbinden…" + @State private var error: String? + + var body: some View { + ZStack { + LinearGradient( + colors: [ + Color(red: 0.12, green: 0.04, blue: 0.06), + McColors.bg, + Color(red: 0.04, green: 0.05, blue: 0.1), + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .ignoresSafeArea() + + VStack(spacing: 18) { + Text("VMC") + .font(McFonts.brand) + .foregroundStyle(McColors.accent) + .shadow(color: McColors.accent.opacity(0.4), radius: 18) + + Text("Koppel dit apparaat") + .font(McFonts.title) + .foregroundStyle(McColors.text) + + Text("Open je Vonas-account, voer deze code in en bevestig.") + .font(McFonts.body) + .foregroundStyle(McColors.textDim) + .multilineTextAlignment(.center) + .padding(.horizontal, 28) + + if let code { + Text(code) + .font(.system(size: 42, weight: .bold, design: .monospaced)) + .foregroundStyle(McColors.text) + .padding(.vertical, 10) + .padding(.horizontal, 22) + .background(McColors.surface.opacity(0.9), in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(McColors.accent.opacity(0.35), lineWidth: 1) + ) + } else if error == nil { + ProgressView().tint(.white) + } + + if !linkUrl.isEmpty { + Text(linkUrl) + .font(McFonts.caption) + .foregroundStyle(McColors.textDim) + } + + Text(status) + .font(McFonts.caption) + .foregroundStyle(McColors.textDim) + + if let error { + Text(error) + .font(McFonts.body) + .foregroundStyle(McColors.danger) + .multilineTextAlignment(.center) + .padding(.horizontal) + Button("Opnieuw") { Task { await startLink() } } + .buttonStyle(McPrimaryButtonStyle()) + } + } + .padding() + .frame(maxWidth: 420) + } + .task { await startLink() } + } + + private func startLink() async { + error = nil + code = nil + status = "Verbinden…" + await app.api.configure(baseUrl: app.store.apiBase, token: nil) + do { + let created = try await app.api.createDeviceCode(deviceName: AppModel.deviceName) + code = created.code + linkUrl = created.linkUrl ?? "" + status = "Wachten tot je de code bevestigt…" + let interval = max(created.pollIntervalSeconds ?? 2, 2) + while true { + try await Task.sleep(nanoseconds: UInt64(interval) * 1_000_000_000) + switch try await app.api.pollDeviceCode(code: created.code) { + case .linked(let token, let name): + status = "Welkom, \(name)" + try await Task.sleep(nanoseconds: 700_000_000) + await app.didLink(token: token) + return + case .expired: + status = "Code verlopen" + error = "Code verlopen — probeer opnieuw" + return + case .alreadyLinked: + status = "Al gekoppeld" + error = "Al gekoppeld — herstart de app" + return + case .pending: + break + } + } + } catch { + self.error = error.localizedDescription + status = "Mislukt" + } + } +} diff --git a/apps/ios/VMC/UI/LiveTvScreen.swift b/apps/ios/VMC/UI/LiveTvScreen.swift new file mode 100644 index 0000000..8ab81f1 --- /dev/null +++ b/apps/ios/VMC/UI/LiveTvScreen.swift @@ -0,0 +1,206 @@ +import SwiftUI + +struct LiveTvScreen: View { + @EnvironmentObject private var app: AppModel + @State private var categories: [IptvCategory] = [] + @State private var channels: [IptvChannel] = [] + @State private var selectedCategory: String? + @State private var error: String? + @State private var loading = true + @State private var playInfo: IptvPlayInfo? + @State private var query = "" + + private static let lastCategoryKey = "vmc.iptv.last_category" + + private var filtered: [IptvChannel] { + let q = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !q.isEmpty else { return channels } + return channels.filter { + $0.name.lowercased().contains(q) || ($0.nowTitle?.lowercased().contains(q) ?? false) + } + } + + var body: some View { + Group { + if loading && categories.isEmpty { + ProgressView().tint(.white).frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error, categories.isEmpty { + VStack(spacing: 12) { + Text(error).foregroundStyle(McColors.danger).multilineTextAlignment(.center) + Button("Opnieuw") { Task { await loadCategories() } }.buttonStyle(McPrimaryButtonStyle()) + } + .padding() + } else { + VStack(spacing: 0) { + HStack(spacing: 10) { + Image(systemName: "magnifyingglass").foregroundStyle(McColors.textDim) + TextField("Zoek zender…", text: $query) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .foregroundStyle(McColors.text) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(McColors.surface, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .padding(.horizontal, 16) + .padding(.top, 8) + .padding(.bottom, 10) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(categories) { cat in + categoryChip(id: cat.id, title: cat.name) + } + } + .padding(.horizontal, 16) + .padding(.bottom, 8) + } + + if filtered.isEmpty { + ContentUnavailableView("Geen zenders", systemImage: "tv") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List(filtered) { ch in + Button { Task { await play(ch) } } label: { + HStack(spacing: 12) { + ZStack { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color.white.opacity(0.06)) + .frame(width: 52, height: 52) + RemoteImage(url: ch.logoUrl, contentMode: .fit, opaqueBackground: false) + .frame(width: 40, height: 40) + } + + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + if let n = ch.number { + Text("\(n)") + .font(.system(size: 11, weight: .bold, design: .rounded)) + .foregroundStyle(McColors.textDim) + .frame(minWidth: 22, alignment: .leading) + } + Text(ch.name) + .font(McFonts.headline) + .foregroundStyle(McColors.text) + .lineLimit(1) + } + Text(ch.nowTitle?.isEmpty == false ? ch.nowTitle! : "Live uitzending") + .font(McFonts.caption) + .foregroundStyle(McColors.textDim) + .lineLimit(1) + } + + Spacer(minLength: 0) + + Image(systemName: "play.fill") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(.white) + .padding(10) + .background(McColors.accent, in: Circle()) + } + .padding(.vertical, 4) + } + .listRowBackground(McColors.bgElevated) + .listRowSeparatorTint(Color.white.opacity(0.06)) + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + } + } + } + } + .background(McColors.bg.ignoresSafeArea()) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .principal) { + BrandHeader(subtitle: "LIVE TV") + } + } + .toolbarBackground(McColors.bg, for: .navigationBar) + .toolbarColorScheme(.dark, for: .navigationBar) + .task { await loadCategories() } + .fullScreenCover(item: Binding( + get: { playInfo.map { LivePlayItem(info: $0) } }, + set: { playInfo = $0?.info } + )) { item in + if let url = URL(string: item.info.streamUrl) { + let fallback = item.info.fallbackStreamUrl.flatMap(URL.init(string:)) + PlayerScreen(request: .live(url: url, title: item.info.name, fallback: fallback)) + .environmentObject(app) + } + } + } + + private func categoryChip(id: String, title: String) -> some View { + let selected = selectedCategory == id + return Button { + selectedCategory = id + UserDefaults.standard.set(id, forKey: Self.lastCategoryKey) + Task { await loadChannels() } + } label: { + Text(title) + .font(McFonts.caption) + .foregroundStyle(selected ? .white : McColors.textDim) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(selected ? McColors.accent : McColors.surface, in: Capsule()) + } + .buttonStyle(.plain) + } + + private func loadCategories() async { + loading = true + error = nil + do { + let raw = try await app.api.loadIptvCategories() + // Geen synthetische "Alles"-chip; API heeft al "Alle zenders" e.d. + categories = raw.filter { cat in + let n = cat.name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return n != "alles" && n != "all" + } + + let saved = UserDefaults.standard.string(forKey: Self.lastCategoryKey) + if let saved, categories.contains(where: { $0.id == saved }) { + selectedCategory = saved + } else if let preferred = categories.first(where: { + let n = $0.name.lowercased() + return n.contains("alle zender") || n == "alle" || n.contains("all channel") + }) { + selectedCategory = preferred.id + } else { + selectedCategory = categories.first?.id + } + + if let id = selectedCategory { + UserDefaults.standard.set(id, forKey: Self.lastCategoryKey) + } + + await loadChannels() + loading = false + } catch { + self.error = error.localizedDescription + loading = false + } + } + + private func loadChannels() async { + do { + channels = try await app.api.iptvChannels(categoryId: selectedCategory, includeEpg: true) + } catch { + self.error = error.localizedDescription + } + } + + private func play(_ ch: IptvChannel) async { + do { + playInfo = try await app.api.iptvPlay(streamId: ch.streamId) + } catch { + self.error = error.localizedDescription + } + } +} + +private struct LivePlayItem: Identifiable { + var id: Int { info.streamId } + let info: IptvPlayInfo +} diff --git a/apps/ios/VMC/UI/MainShell.swift b/apps/ios/VMC/UI/MainShell.swift new file mode 100644 index 0000000..f775172 --- /dev/null +++ b/apps/ios/VMC/UI/MainShell.swift @@ -0,0 +1,80 @@ +import SwiftUI + +struct MainShell: View { + @EnvironmentObject private var app: AppModel + + var body: some View { + ZStack(alignment: .bottom) { + Group { + switch app.tab { + case .home: + NavigationStack { HomeScreen() } + case .movies: + NavigationStack { BrowseScreen(kind: "MOVIE", title: "Films") } + case .series: + NavigationStack { BrowseScreen(kind: "SERIES", title: "Series") } + case .tv: + NavigationStack { LiveTvScreen() } + case .search: + NavigationStack { SearchScreen() } + case .account: + NavigationStack { SettingsScreen() } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.bottom, app.isPlaybackActive ? 0 : 58) + + if !app.isPlaybackActive { + footer + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + .animation(.easeOut(duration: 0.2), value: app.isPlaybackActive) + .ignoresSafeArea(.keyboard) + .background(McColors.bg.ignoresSafeArea()) + } + + private var footer: some View { + HStack(spacing: 0) { + ForEach(visibleTabs) { tab in + Button { + withAnimation(.spring(response: 0.32, dampingFraction: 0.84)) { + app.tab = tab + } + } label: { + VStack(spacing: 3) { + Image(systemName: tab.icon) + .font(.system(size: 16, weight: .semibold)) + Text(tab.title) + .font(.system(size: 9, weight: .semibold, design: .rounded)) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + .foregroundStyle(app.tab == tab ? McColors.accent : McColors.textDim) + .frame(maxWidth: .infinity) + .padding(.top, 8) + .padding(.bottom, 6) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } + .background( + ZStack(alignment: .top) { + Rectangle().fill(.ultraThinMaterial) + McColors.footer + Rectangle() + .fill(McColors.surfaceSoft.opacity(0.55)) + .frame(height: 0.5) + } + .ignoresSafeArea(edges: .bottom) + ) + } + + private var visibleTabs: [AppModel.MainTab] { + AppModel.MainTab.allCases.filter { tab in + if tab == .tv { return app.iptvEnabled } + return true + } + } +} diff --git a/apps/ios/VMC/UI/ProfilesScreen.swift b/apps/ios/VMC/UI/ProfilesScreen.swift new file mode 100644 index 0000000..aeee798 --- /dev/null +++ b/apps/ios/VMC/UI/ProfilesScreen.swift @@ -0,0 +1,233 @@ +import SwiftUI + +struct ProfilesScreen: View { + @EnvironmentObject private var app: AppModel + @State private var profiles: [ViewerProfile] = [] + @State private var error: String? + @State private var pinTarget: ViewerProfile? + @State private var pinDigits = "" + @State private var pinError: String? + @State private var loading = true + @State private var selecting = false + + private let cols = [GridItem(.adaptive(minimum: 110), spacing: 20)] + + var body: some View { + VStack(spacing: 28) { + Text("Wie kijkt er?") + .font(McFonts.title) + .foregroundStyle(McColors.text) + .padding(.top, 48) + + if loading { + ProgressView().tint(.white) + } else if let error, profiles.isEmpty { + Text(error).foregroundStyle(McColors.danger) + Button("Opnieuw") { Task { await load() } } + .buttonStyle(McPrimaryButtonStyle()) + } else { + if let error { + Text(error) + .font(McFonts.caption) + .foregroundStyle(McColors.danger) + .padding(.horizontal) + } + LazyVGrid(columns: cols, spacing: 24) { + ForEach(profiles) { profile in + Button { + Task { await select(profile) } + } label: { + VStack(spacing: 10) { + Text(ProfileAvatars.emoji(for: profile.avatarId)) + .font(.system(size: 48)) + .frame(width: 96, height: 96) + .background( + LinearGradient( + colors: [McColors.surfaceSoft, McColors.surface], + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + in: Circle() + ) + .overlay(Circle().stroke(McColors.accent.opacity(0.25), lineWidth: 1)) + Text(profile.name) + .font(McFonts.headline) + .foregroundStyle(McColors.text) + .lineLimit(1) + if profile.hasPin == true { + Image(systemName: "lock.fill") + .font(.caption2) + .foregroundStyle(McColors.textDim) + } + } + } + .buttonStyle(.plain) + .disabled(selecting) + } + } + .padding(.horizontal, 24) + } + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background( + LinearGradient(colors: [Color(red: 0.08, green: 0.03, blue: 0.04), McColors.bg], startPoint: .top, endPoint: .bottom) + .ignoresSafeArea() + ) + .task { await load() } + .sheet(item: $pinTarget) { profile in + PinPadSheet( + profileName: profile.name, + avatarId: profile.avatarId, + digits: $pinDigits, + error: pinError, + busy: selecting, + onCancel: { + pinTarget = nil + pinDigits = "" + pinError = nil + }, + onSubmit: { code in + Task { await submitPin(for: profile, code: code) } + } + ) + .presentationDetents([.height(420)]) + .presentationDragIndicator(.visible) + } + } + + private func load() async { + loading = true + error = nil + do { + let payload = try await app.api.listProfiles() + profiles = payload.profiles + loading = false + } catch { + self.error = error.localizedDescription + loading = false + } + } + + private func select(_ profile: ViewerProfile, pin: String? = nil) async { + if profile.hasPin == true, pin == nil { + pinDigits = "" + pinError = nil + pinTarget = profile + return + } + selecting = true + error = nil + do { + let selected = try await app.api.selectProfile(profileId: profile.id, pin: pin) + pinTarget = nil + pinDigits = "" + pinError = nil + selecting = false + app.didSelectProfile(selected) + } catch { + selecting = false + if pinTarget != nil { + pinError = error.localizedDescription + pinDigits = "" + } else { + self.error = error.localizedDescription + } + } + } + + private func submitPin(for profile: ViewerProfile, code: String) async { + let digits = code.filter(\.isNumber) + guard digits.count == 4 else { + pinError = "PIN moet 4 cijfers zijn" + return + } + await select(profile, pin: String(digits)) + } +} + +struct PinPadSheet: View { + let profileName: String + let avatarId: String? + @Binding var digits: String + let error: String? + let busy: Bool + let onCancel: () -> Void + let onSubmit: (String) -> Void + + private let keys = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "", "0", "⌫"] + + var body: some View { + VStack(spacing: 18) { + HStack { + Button("Annuleer", action: onCancel) + .foregroundStyle(McColors.textDim) + Spacer() + } + .padding(.horizontal) + + Text(ProfileAvatars.emoji(for: avatarId)) + .font(.system(size: 40)) + Text("PIN voor \(profileName)") + .font(McFonts.headline) + .foregroundStyle(McColors.text) + + HStack(spacing: 12) { + ForEach(0..<4, id: \.self) { i in + Circle() + .fill(i < digits.count ? McColors.accent : McColors.surfaceSoft) + .frame(width: 14, height: 14) + } + } + .padding(.vertical, 4) + + if let error { + Text(error) + .font(McFonts.caption) + .foregroundStyle(McColors.danger) + } + + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: 3), spacing: 12) { + ForEach(keys, id: \.self) { key in + if key.isEmpty { + Color.clear.frame(height: 52) + } else { + Button { + tap(key) + } label: { + Text(key) + .font(.system(size: 24, weight: .semibold, design: .rounded)) + .foregroundStyle(McColors.text) + .frame(maxWidth: .infinity) + .frame(height: 52) + .background(McColors.surface, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + .buttonStyle(.plain) + .disabled(busy) + } + } + } + .padding(.horizontal, 28) + + if busy { + ProgressView().tint(.white) + } + } + .padding(.top, 16) + .padding(.bottom, 24) + .background(McColors.bgElevated.ignoresSafeArea()) + } + + private func tap(_ key: String) { + if key == "⌫" { + if !digits.isEmpty { digits.removeLast() } + return + } + guard digits.count < 4 else { return } + digits.append(key) + if digits.count == 4 { + onSubmit(digits) + } + } +} diff --git a/apps/ios/VMC/UI/QualityBadges.swift b/apps/ios/VMC/UI/QualityBadges.swift new file mode 100644 index 0000000..f3e1249 --- /dev/null +++ b/apps/ios/VMC/UI/QualityBadges.swift @@ -0,0 +1,158 @@ +import SwiftUI + +enum ResolutionLogo: String { + case uhd4k = "4K" + case fhd1080 = "1080p" + case hd720 = "720p" + case sd = "SD" +} + +enum FeatureLogo: String { + case dolbyVision = "Dolby Vision" + case hdr = "HDR" + case atmos = "Atmos" + case dtsX = "DTS:X" +} + +struct MediaVersionBadges { + let resolution: ResolutionLogo? + let features: [FeatureLogo] + + static func parse(from file: MediaFileInfo) -> MediaVersionBadges { + let hay = [file.resolution, file.hdrType, file.audioCodec, file.videoCodec, file.label] + .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .joined(separator: " ") + return MediaVersionBadges( + resolution: parseResolution(hay), + features: parseFeatures(hay) + ) + } + + private static func parseResolution(_ hay: String) -> ResolutionLogo? { + guard !hay.isEmpty else { return nil } + if hay.range(of: #"(?:^|[^\d])(2160\s*p?|3840\s*[x×]\s*2160|4\s*k|uhd)(?:[^\d]|$)"#, options: [.regularExpression, .caseInsensitive]) != nil { + return .uhd4k + } + if hay.range(of: #"(?:^|[^\d])(1080\s*p?|1920\s*[x×]\s*1080|fhd)(?:[^\d]|$)"#, options: [.regularExpression, .caseInsensitive]) != nil { + return .fhd1080 + } + if hay.range(of: #"(?:^|[^\d])(720\s*p?|1280\s*[x×]\s*720)(?:[^\d]|$)"#, options: [.regularExpression, .caseInsensitive]) != nil { + return .hd720 + } + if hay.range(of: #"(?:^|[^\d])(480\s*p?|576\s*p?|sd)(?:[^\d]|$)"#, options: [.regularExpression, .caseInsensitive]) != nil { + return .sd + } + return nil + } + + private static func parseFeatures(_ hay: String) -> [FeatureLogo] { + guard !hay.isEmpty else { return [] } + var out: [FeatureLogo] = [] + if hay.range(of: #"dolby\s*vision|\bdovi\b|\bdv\b"#, options: [.regularExpression, .caseInsensitive]) != nil { + out.append(.dolbyVision) + } else if hay.range(of: #"hdr10\+?|\bhdr\b"#, options: [.regularExpression, .caseInsensitive]) != nil { + out.append(.hdr) + } + if hay.range(of: #"atmos"#, options: [.regularExpression, .caseInsensitive]) != nil { + out.append(.atmos) + } else if hay.range(of: #"dts[\s.:_-]?x\b"#, options: [.regularExpression, .caseInsensitive]) != nil { + out.append(.dtsX) + } + return out + } +} + +struct QualityBadgeRow: View { + let file: MediaFileInfo + + var body: some View { + let badges = MediaVersionBadges.parse(from: file) + HStack(spacing: 8) { + if let res = badges.resolution { + ResolutionChip(logo: res) + } else { + Text("Versie") + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .foregroundStyle(McColors.textDim) + } + ForEach(badges.features, id: \.rawValue) { feature in + FeatureChip(logo: feature) + } + if file.nodeOnline == false { + Text("offline") + .font(.system(size: 11, weight: .bold, design: .rounded)) + .foregroundStyle(McColors.danger) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(McColors.danger.opacity(0.15), in: RoundedRectangle(cornerRadius: 4, style: .continuous)) + } + Spacer(minLength: 0) + } + } +} + +private struct ResolutionChip: View { + let logo: ResolutionLogo + + var body: some View { + switch logo { + case .uhd4k: + plate("4K", fg: .black, bg: Color(white: 0.95), weight: .black, tracking: 0.8) + case .fhd1080: + plate("1080p", fg: .white, bg: Color(white: 0.1), weight: .bold, border: Color.white.opacity(0.65)) + case .hd720: + plate("720p", fg: Color(white: 0.88), bg: .clear, weight: .semibold, border: Color.white.opacity(0.45)) + case .sd: + plate("SD", fg: Color(white: 0.74), bg: .clear, weight: .semibold, border: Color.white.opacity(0.35)) + } + } + + private func plate( + _ text: String, + fg: Color, + bg: Color, + weight: Font.Weight, + tracking: CGFloat = 0.2, + border: Color? = nil + ) -> some View { + Text(text) + .font(.system(size: 12, weight: weight, design: .rounded)) + .tracking(tracking) + .foregroundStyle(fg) + .padding(.horizontal, 9) + .frame(minWidth: 36, minHeight: 26) + .background(bg, in: RoundedRectangle(cornerRadius: 4, style: .continuous)) + .overlay { + if let border { + RoundedRectangle(cornerRadius: 4, style: .continuous) + .strokeBorder(border, lineWidth: 1) + } + } + } +} + +private struct FeatureChip: View { + let logo: FeatureLogo + + var body: some View { + let text: String = { + switch logo { + case .dolbyVision: return "VISION" + case .hdr: return "HDR" + case .atmos: return "ATMOS" + case .dtsX: return "DTS:X" + } + }() + Text(text) + .font(.system(size: 10, weight: .bold, design: .rounded)) + .tracking(0.6) + .foregroundStyle(McColors.accent) + .padding(.horizontal, 8) + .frame(minHeight: 26) + .overlay { + RoundedRectangle(cornerRadius: 4, style: .continuous) + .strokeBorder(McColors.accent.opacity(0.55), lineWidth: 1) + } + } +} diff --git a/apps/ios/VMC/UI/RootView.swift b/apps/ios/VMC/UI/RootView.swift new file mode 100644 index 0000000..bc8d10d --- /dev/null +++ b/apps/ios/VMC/UI/RootView.swift @@ -0,0 +1,43 @@ +import SwiftUI + +struct RootView: View { + @EnvironmentObject private var app: AppModel + + var body: some View { + ZStack { + McColors.bg.ignoresSafeArea() + switch app.phase { + case .boot: + SplashView() + case .link: + LinkScreen() + case .profiles: + ProfilesScreen() + case .main: + MainShell() + } + } + .animation(.easeInOut(duration: 0.28), value: app.phase) + } +} + +struct SplashView: View { + @State private var pulse = false + + var body: some View { + VStack(spacing: 18) { + Text("VMC") + .font(McFonts.brand) + .foregroundStyle(McColors.accent) + .scaleEffect(pulse ? 1.04 : 0.96) + .shadow(color: McColors.accent.opacity(0.45), radius: pulse ? 24 : 8) + ProgressView() + .tint(McColors.textDim) + } + .onAppear { + withAnimation(.easeInOut(duration: 1.1).repeatForever(autoreverses: true)) { + pulse = true + } + } + } +} diff --git a/apps/ios/VMC/UI/SearchScreen.swift b/apps/ios/VMC/UI/SearchScreen.swift new file mode 100644 index 0000000..957fab4 --- /dev/null +++ b/apps/ios/VMC/UI/SearchScreen.swift @@ -0,0 +1,101 @@ +import SwiftUI + +struct SearchScreen: View { + @EnvironmentObject private var app: AppModel + @State private var query = "" + @State private var results: [MediaItem] = [] + @State private var error: String? + @State private var searching = false + + private let cols = [GridItem(.adaptive(minimum: 104), spacing: 12)] + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 10) { + Image(systemName: "magnifyingglass") + .foregroundStyle(McColors.textDim) + TextField("Films, series…", text: $query) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .foregroundStyle(McColors.text) + .submitLabel(.search) + .onSubmit { Task { await runSearch() } } + if !query.isEmpty { + Button { + query = "" + results = [] + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(McColors.textDim) + } + } + } + .padding(12) + .background(McColors.surface, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .padding(.horizontal, 16) + .padding(.top, 8) + .padding(.bottom, 12) + + if searching { + ProgressView().tint(.white).padding(.top, 40) + Spacer() + } else if let error { + Text(error).foregroundStyle(McColors.danger).padding() + Spacer() + } else if results.isEmpty && query.count >= 2 { + ContentUnavailableView("Geen resultaten", systemImage: "magnifyingglass") + } else if results.isEmpty { + ContentUnavailableView("Zoeken in VMC", systemImage: "sparkle.magnifyingglass", description: Text("Typ minstens 2 tekens.")) + } else { + ScrollView { + LazyVGrid(columns: cols, spacing: 14) { + ForEach(results) { item in + NavigationLink(value: item) { + PosterCard(item: item, width: 104) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 16) + .padding(.bottom, 24) + } + .navigationDestination(for: MediaItem.self) { MediaRouter(item: $0) } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(McColors.bg.ignoresSafeArea()) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .principal) { + BrandHeader(subtitle: "ZOEKEN") + } + } + .toolbarBackground(McColors.bg, for: .navigationBar) + .toolbarColorScheme(.dark, for: .navigationBar) + .onChange(of: query) { _, newValue in + Task { + try? await Task.sleep(nanoseconds: 350_000_000) + guard newValue == query else { return } + await runSearch() + } + } + } + + private func runSearch() async { + let q = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard q.count >= 2 else { + results = [] + error = nil + return + } + searching = true + error = nil + do { + results = try await app.api.search(query: q) + searching = false + } catch { + self.error = error.localizedDescription + searching = false + } + } +} diff --git a/apps/ios/VMC/UI/SettingsScreen.swift b/apps/ios/VMC/UI/SettingsScreen.swift new file mode 100644 index 0000000..f4e6438 --- /dev/null +++ b/apps/ios/VMC/UI/SettingsScreen.swift @@ -0,0 +1,535 @@ +import SwiftUI + +struct SettingsScreen: View { + @EnvironmentObject private var app: AppModel + + var body: some View { + List { + Section { + NavigationLink { + EditProfileScreen(profileId: app.store.profileId) + } label: { + HStack(spacing: 14) { + Text(ProfileAvatars.emoji(for: app.store.profileAvatarId)) + .font(.system(size: 40)) + .frame(width: 64, height: 64) + .background( + LinearGradient(colors: [McColors.surfaceSoft, McColors.surface], startPoint: .top, endPoint: .bottom), + in: Circle() + ) + VStack(alignment: .leading, spacing: 4) { + Text(app.store.profileName ?? "Profiel") + .font(McFonts.headline) + .foregroundStyle(McColors.text) + Text("Naam & avatar wijzigen") + .font(McFonts.caption) + .foregroundStyle(McColors.textDim) + } + } + } + .listRowBackground(McColors.bgElevated) + } + + Section("Bibliotheek") { + NavigationLink { + ShelfPrefsScreen() + } label: { + Label("Planken beheren", systemImage: "rectangle.stack") + .foregroundStyle(McColors.text) + } + .listRowBackground(McColors.bgElevated) + } + + Section("Profielen") { + Button { + Task { await app.switchProfile() } + } label: { + Label("Profiel wisselen", systemImage: "person.2") + .foregroundStyle(McColors.text) + } + .listRowBackground(McColors.bgElevated) + + if app.canManageProfiles { + NavigationLink { + ManageProfilesScreen() + } label: { + Label("Profielen beheren", systemImage: "person.crop.circle.badge.plus") + .foregroundStyle(McColors.text) + } + .listRowBackground(McColors.bgElevated) + } + } + + Section { + Button(role: .destructive) { + Task { await app.logout() } + } label: { + Label("Uitloggen", systemImage: "rectangle.portrait.and.arrow.right") + } + .listRowBackground(McColors.bgElevated) + } + + Section("Info") { + LabeledContent("App", value: "VMC iOS") + LabeledContent("Versie", value: "0.4.3") + LabeledContent("Live TV", value: app.iptvEnabled ? "Aan" : "Uit") + } + .listRowBackground(McColors.bgElevated) + } + .scrollContentBackground(.hidden) + .background(McColors.bg.ignoresSafeArea()) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .principal) { + BrandHeader(subtitle: "ACCOUNT") + } + } + .toolbarBackground(McColors.bg, for: .navigationBar) + .toolbarColorScheme(.dark, for: .navigationBar) + } +} + +struct EditProfileScreen: View { + @EnvironmentObject private var app: AppModel + let profileId: String? + + @State private var name = "" + @State private var avatarId = "fox" + @State private var pin = "" + @State private var clearPin = false + @State private var message: String? + @State private var error: String? + @State private var saving = false + + private let cols = [GridItem(.adaptive(minimum: 64), spacing: 12)] + + var body: some View { + Form { + Section("Naam") { + TextField("Naam", text: $name) + .foregroundStyle(McColors.text) + } + .listRowBackground(McColors.bgElevated) + + Section("Avatar") { + LazyVGrid(columns: cols, spacing: 12) { + ForEach(ProfileAvatars.all, id: \.id) { avatar in + Button { + avatarId = avatar.id + } label: { + Text(avatar.emoji) + .font(.system(size: 28)) + .frame(width: 56, height: 56) + .background( + avatarId == avatar.id ? McColors.accent.opacity(0.25) : McColors.surface, + in: Circle() + ) + .overlay( + Circle().stroke(avatarId == avatar.id ? McColors.accent : Color.clear, lineWidth: 2) + ) + } + .buttonStyle(.plain) + } + } + .padding(.vertical, 4) + } + .listRowBackground(McColors.bgElevated) + + Section("PIN (optioneel)") { + SecureField("Nieuwe PIN (4 cijfers)", text: $pin) + .keyboardType(.numberPad) + .foregroundStyle(McColors.text) + Toggle("PIN verwijderen", isOn: $clearPin) + .tint(McColors.accent) + } + .listRowBackground(McColors.bgElevated) + + if let message { + Section { Text(message).foregroundStyle(McColors.success) } + .listRowBackground(McColors.bgElevated) + } + if let error { + Section { Text(error).foregroundStyle(McColors.danger) } + .listRowBackground(McColors.bgElevated) + } + + Section { + Button { + Task { await save() } + } label: { + if saving { ProgressView() } else { Text("Opslaan") } + } + .disabled(saving || name.trimmingCharacters(in: .whitespaces).isEmpty) + .listRowBackground(McColors.bgElevated) + } + } + .scrollContentBackground(.hidden) + .background(McColors.bg.ignoresSafeArea()) + .navigationTitle("Mijn profiel") + .navigationBarTitleDisplayMode(.inline) + .task { + name = app.store.profileName ?? "" + avatarId = app.store.profileAvatarId ?? "fox" + } + } + + private func save() async { + guard let profileId else { return } + saving = true + error = nil + message = nil + do { + let updated = try await app.api.updateProfile( + profileId: profileId, + name: name.trimmingCharacters(in: .whitespacesAndNewlines), + avatarId: avatarId, + pin: clearPin ? nil : (pin.isEmpty ? nil : pin), + clearPin: clearPin + ) + app.store.setProfile(id: updated.id, name: updated.name, avatarId: updated.avatarId) + message = "Opgeslagen" + pin = "" + clearPin = false + saving = false + } catch { + self.error = error.localizedDescription + saving = false + } + } +} + +struct ManageProfilesScreen: View { + @EnvironmentObject private var app: AppModel + @State private var profiles: [ViewerProfile] = [] + @State private var canManage = false + @State private var error: String? + @State private var showCreate = false + + var body: some View { + List { + ForEach(profiles) { profile in + NavigationLink { + AdminEditProfileScreen(profile: profile, canManage: canManage) + } label: { + HStack(spacing: 12) { + Text(ProfileAvatars.emoji(for: profile.avatarId)).font(.title2) + VStack(alignment: .leading, spacing: 2) { + Text(profile.name).foregroundStyle(McColors.text) + Text(profile.isOwner == true ? "Hoofdprofiel" : (profile.isKids == true ? "Kids" : "Profiel")) + .font(McFonts.caption) + .foregroundStyle(McColors.textDim) + } + } + } + .listRowBackground(McColors.bgElevated) + } + + if canManage && profiles.count < 8 { + Button { + showCreate = true + } label: { + Label("Profiel toevoegen", systemImage: "plus.circle.fill") + .foregroundStyle(McColors.accent) + } + .listRowBackground(McColors.bgElevated) + } + + if let error { + Text(error).foregroundStyle(McColors.danger) + .listRowBackground(McColors.bgElevated) + } + } + .scrollContentBackground(.hidden) + .background(McColors.bg.ignoresSafeArea()) + .navigationTitle("Profielen") + .navigationBarTitleDisplayMode(.inline) + .task { await load() } + .sheet(isPresented: $showCreate) { + CreateProfileSheet { + showCreate = false + Task { await load() } + } + .environmentObject(app) + } + } + + private func load() async { + do { + let payload = try await app.api.listProfiles() + profiles = payload.profiles + canManage = payload.canManage == true + app.canManageProfiles = canManage + } catch { + self.error = error.localizedDescription + } + } +} + +struct CreateProfileSheet: View { + @EnvironmentObject private var app: AppModel + let onDone: () -> Void + + @State private var name = "" + @State private var avatarId = "fox" + @State private var isKids = false + @State private var pin = "" + @State private var error: String? + @State private var saving = false + + private let cols = [GridItem(.adaptive(minimum: 56), spacing: 10)] + + var body: some View { + NavigationStack { + Form { + Section("Naam") { + TextField("Naam", text: $name) + } + .listRowBackground(McColors.bgElevated) + + Section("Avatar") { + LazyVGrid(columns: cols, spacing: 10) { + ForEach(ProfileAvatars.all, id: \.id) { avatar in + Button { avatarId = avatar.id } label: { + Text(avatar.emoji) + .font(.title2) + .frame(width: 48, height: 48) + .background(avatarId == avatar.id ? McColors.accent.opacity(0.25) : McColors.surface, in: Circle()) + } + .buttonStyle(.plain) + } + } + } + .listRowBackground(McColors.bgElevated) + + Section { + Toggle("Kids-profiel", isOn: $isKids).tint(McColors.accent) + SecureField("PIN (optioneel)", text: $pin).keyboardType(.numberPad) + } + .listRowBackground(McColors.bgElevated) + + if let error { + Text(error).foregroundStyle(McColors.danger) + .listRowBackground(McColors.bgElevated) + } + } + .scrollContentBackground(.hidden) + .background(McColors.bg.ignoresSafeArea()) + .navigationTitle("Nieuw profiel") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Annuleer", action: onDone) + } + ToolbarItem(placement: .confirmationAction) { + Button("Maak") { + Task { await create() } + } + .disabled(saving || name.trimmingCharacters(in: .whitespaces).isEmpty) + } + } + } + } + + private func create() async { + saving = true + error = nil + do { + _ = try await app.api.createProfile( + name: name.trimmingCharacters(in: .whitespacesAndNewlines), + avatarId: avatarId, + isKids: isKids, + pin: pin.isEmpty ? nil : pin + ) + saving = false + onDone() + } catch { + self.error = error.localizedDescription + saving = false + } + } +} + +struct AdminEditProfileScreen: View { + @EnvironmentObject private var app: AppModel + let profile: ViewerProfile + let canManage: Bool + + @Environment(\.dismiss) private var dismiss + @State private var name = "" + @State private var avatarId = "fox" + @State private var isKids = false + @State private var pin = "" + @State private var clearPin = false + @State private var error: String? + @State private var message: String? + + var body: some View { + Form { + Section("Naam") { + TextField("Naam", text: $name) + } + .listRowBackground(McColors.bgElevated) + + Section("Avatar") { + ScrollView(.horizontal, showsIndicators: false) { + HStack { + ForEach(ProfileAvatars.all, id: \.id) { avatar in + Button { avatarId = avatar.id } label: { + Text(avatar.emoji) + .font(.title) + .padding(8) + .background(avatarId == avatar.id ? McColors.accent.opacity(0.25) : McColors.surface, in: Circle()) + } + .buttonStyle(.plain) + } + } + } + } + .listRowBackground(McColors.bgElevated) + + if canManage && profile.isOwner != true { + Section { + Toggle("Kids-profiel", isOn: $isKids).tint(McColors.accent) + } + .listRowBackground(McColors.bgElevated) + } + + Section("PIN") { + SecureField("Nieuwe PIN", text: $pin).keyboardType(.numberPad) + Toggle("PIN verwijderen", isOn: $clearPin).tint(McColors.accent) + } + .listRowBackground(McColors.bgElevated) + + Section { + Button("Opslaan") { Task { await save() } } + .listRowBackground(McColors.bgElevated) + } + + if canManage && profile.isOwner != true { + Section { + Button("Verwijderen", role: .destructive) { + Task { await remove() } + } + .listRowBackground(McColors.bgElevated) + } + } + + if let message { Text(message).foregroundStyle(McColors.success).listRowBackground(McColors.bgElevated) } + if let error { Text(error).foregroundStyle(McColors.danger).listRowBackground(McColors.bgElevated) } + } + .scrollContentBackground(.hidden) + .background(McColors.bg.ignoresSafeArea()) + .navigationTitle(profile.name) + .task { + name = profile.name + avatarId = profile.avatarId ?? "fox" + isKids = profile.isKids == true + } + } + + private func save() async { + do { + let updated = try await app.api.updateProfile( + profileId: profile.id, + name: name.trimmingCharacters(in: .whitespacesAndNewlines), + avatarId: avatarId, + isKids: canManage && profile.isOwner != true ? isKids : nil, + pin: clearPin ? nil : (pin.isEmpty ? nil : pin), + clearPin: clearPin + ) + if updated.id == app.store.profileId { + app.store.setProfile(id: updated.id, name: updated.name, avatarId: updated.avatarId) + } + message = "Opgeslagen" + error = nil + } catch { + self.error = error.localizedDescription + } + } + + private func remove() async { + do { + try await app.api.deleteProfile(profileId: profile.id) + dismiss() + } catch { + self.error = error.localizedDescription + } + } +} + +struct ShelfPrefsScreen: View { + @EnvironmentObject private var app: AppModel + @State private var prefs: [ShelfPref] = [] + @State private var error: String? + @State private var message: String? + + var body: some View { + List { + Section { + Text("Zet planken aan/uit en sleep om te sorteren. Dit bepaalt Home én Films/Series.") + .font(McFonts.caption) + .foregroundStyle(McColors.textDim) + } + .listRowBackground(McColors.bgElevated) + + Section("Planken") { + ForEach($prefs) { $pref in + Toggle(isOn: $pref.visible) { + VStack(alignment: .leading, spacing: 2) { + Text(pref.name).foregroundStyle(McColors.text) + Text((pref.kind ?? "").uppercased()) + .font(McFonts.caption) + .foregroundStyle(McColors.textDim) + } + } + .tint(McColors.accent) + .listRowBackground(McColors.bgElevated) + .onChange(of: pref.visible) { _, _ in + Task { await persist() } + } + } + .onMove { from, to in + prefs.move(fromOffsets: from, toOffset: to) + Task { await persist() } + } + } + + if let message { Text(message).foregroundStyle(McColors.success).listRowBackground(McColors.bgElevated) } + if let error { Text(error).foregroundStyle(McColors.danger).listRowBackground(McColors.bgElevated) } + } + .environment(\.editMode, .constant(.active)) + .scrollContentBackground(.hidden) + .background(McColors.bg.ignoresSafeArea()) + .navigationTitle("Planken") + .navigationBarTitleDisplayMode(.inline) + .task { await load() } + } + + private func load() async { + do { + prefs = try await app.api.getShelfPrefs().sorted { $0.sortOrder < $1.sortOrder } + } catch { + self.error = error.localizedDescription + } + } + + private func persist() async { + do { + let next = prefs.enumerated().map { i, p in + ShelfPref( + shelfId: p.shelfId, + name: p.name, + slug: p.slug, + kind: p.kind, + visible: p.visible, + sortOrder: i + ) + } + prefs = next + try await app.api.saveShelfPrefs(next) + message = "Opgeslagen" + error = nil + } catch { + self.error = error.localizedDescription + } + } +} diff --git a/apps/ios/VMC/VMCApp.swift b/apps/ios/VMC/VMCApp.swift new file mode 100644 index 0000000..e3ae844 --- /dev/null +++ b/apps/ios/VMC/VMCApp.swift @@ -0,0 +1,14 @@ +import SwiftUI + +@main +struct VMCApp: App { + @StateObject private var appModel = AppModel() + + var body: some Scene { + WindowGroup { + RootView() + .environmentObject(appModel) + .preferredColorScheme(.dark) + } + } +} diff --git a/apps/ios/scripts/fetch-vlckit.sh b/apps/ios/scripts/fetch-vlckit.sh new file mode 100644 index 0000000..d413cc2 --- /dev/null +++ b/apps/ios/scripts/fetch-vlckit.sh @@ -0,0 +1,49 @@ +#!/bin/zsh +set -euo pipefail +cd /tmp +echo "Listing MobileVLCKit packages..." +curl -fsSL https://download.videolan.org/pub/cocoapods/prod/ -o /tmp/vlc-prod.html +python3 - <<'PY' +from pathlib import Path +import re +html = Path('/tmp/vlc-prod.html').read_text(errors='ignore') +names = sorted(set(re.findall(r'href="(MobileVLCKit-3\.[^"]+)"', html))) +for n in names[-25:]: + print(n) +PY + +# Prefer newest 3.5/3.6 if present else latest 3.x +python3 - <<'PY' +from pathlib import Path +import re +html = Path('/tmp/vlc-prod.html').read_text(errors='ignore') +names = sorted(set(re.findall(r'href="(MobileVLCKit-3\.[^"]+)"', html))) +pick = None +for n in reversed(names): + if n.startswith('MobileVLCKit-3.5') or n.startswith('MobileVLCKit-3.6'): + pick = n + break +if not pick and names: + pick = names[-1] +print('PICK=' + (pick or '')) +Path('/tmp/vlc-pick.txt').write_text(pick or '') +PY + +PICK=$(cat /tmp/vlc-pick.txt) +if [ -z "$PICK" ]; then + echo "No package found" + exit 1 +fi +echo "Downloading $PICK ..." +curl -fL --progress-bar "https://download.videolan.org/pub/cocoapods/prod/$PICK" -o "/tmp/$PICK" +mkdir -p /tmp/vlcunpack +rm -rf /tmp/vlcunpack/* +case "$PICK" in + *.zip) unzip -q "/tmp/$PICK" -d /tmp/vlcunpack ;; + *.tar.xz|*.txz) tar -xJf "/tmp/$PICK" -C /tmp/vlcunpack ;; + *.tar.bz2) tar -xjf "/tmp/$PICK" -C /tmp/vlcunpack ;; + *) tar -xf "/tmp/$PICK" -C /tmp/vlcunpack ;; +esac +echo "Unpacked contents:" +find /tmp/vlcunpack -maxdepth 3 -type d | head -40 +find /tmp/vlcunpack -name '*.framework' -o -name '*.xcframework' | head -20 diff --git a/apps/ios/scripts/link-vlckit.sh b/apps/ios/scripts/link-vlckit.sh new file mode 100644 index 0000000..7033b65 --- /dev/null +++ b/apps/ios/scripts/link-vlckit.sh @@ -0,0 +1,94 @@ +#!/bin/bash +set -euo pipefail +IOS="$HOME/Code/mediacluster/apps/ios" +mkdir -p "$IOS/Vendor" +rm -rf "$IOS/Vendor/MobileVLCKit.xcframework" +cp -R /tmp/vlcunpack/MobileVLCKit-binary/MobileVLCKit.xcframework "$IOS/Vendor/" +ls "$IOS/Vendor/MobileVLCKit.xcframework" + +python3 <<'PY' +from pathlib import Path +import uuid, re + +pbx_path = Path.home() / "Code/mediacluster/apps/ios/VMC.xcodeproj/project.pbxproj" +text = pbx_path.read_text() +if "MobileVLCKit.xcframework" in text: + print("Already linked") + raise SystemExit(0) + +def nid(): + return uuid.uuid4().hex[:24].upper() + +fw_ref, fw_build, embed_build, embed_phase = nid(), nid(), nid(), nid() + +text = text.replace( + "/* End PBXFileReference section */", + f'\t\t{fw_ref} /* MobileVLCKit.xcframework */ = {{isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = MobileVLCKit.xcframework; path = Vendor/MobileVLCKit.xcframework; sourceTree = ""; }};\n/* End PBXFileReference section */', +) + +text = text.replace( + "/* End PBXBuildFile section */", + ( + f"\t\t{fw_build} /* MobileVLCKit.xcframework in Frameworks */ = {{isa = PBXBuildFile; fileRef = {fw_ref} /* MobileVLCKit.xcframework */; }};\n" + f"\t\t{embed_build} /* MobileVLCKit.xcframework in Embed Frameworks */ = {{isa = PBXBuildFile; fileRef = {fw_ref} /* MobileVLCKit.xcframework */; settings = {{ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }}; }};\n" + "/* End PBXBuildFile section */" + ), +) + +text = re.sub( + r"(isa = PBXFrameworksBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = \()\n(\t\t\t\);)", + rf"\1\n\t\t\t\t{fw_build} /* MobileVLCKit.xcframework in Frameworks */,\n\2", + text, + count=1, +) + +if "PBXCopyFilesBuildPhase" not in text: + block = f"""/* Begin PBXCopyFilesBuildPhase section */ +\t\t{embed_phase} /* Embed Frameworks */ = {{ +\t\t\tisa = PBXCopyFilesBuildPhase; +\t\t\tbuildActionMask = 2147483647; +\t\t\tdstPath = ""; +\t\t\tdstSubfolderSpec = 10; +\t\t\tfiles = ( +\t\t\t\t{embed_build} /* MobileVLCKit.xcframework in Embed Frameworks */, +\t\t\t); +\t\t\tname = "Embed Frameworks"; +\t\t\trunOnlyForDeploymentPostprocessing = 0; +\t\t}}; +/* End PBXCopyFilesBuildPhase section */ + +""" + text = text.replace("/* Begin PBXFrameworksBuildPhase section */", block + "/* Begin PBXFrameworksBuildPhase section */") + +text = re.sub( + r"(buildPhases = \(\n\t\t\t\t[A-F0-9]+ /\* Sources \*/,\n\t\t\t\t[A-F0-9]+ /\* Frameworks \*/,)", + rf"\1\n\t\t\t\t{embed_phase} /* Embed Frameworks */,", + text, + count=1, +) + +# Insert into root group children (before Products) +text = re.sub( + r"(children = \(\n\t\t\t\t[A-F0-9]+ /\* VMC \*/,)\n(\t\t\t\t[A-F0-9]+ /\* Products \*/,)", + rf"\1\n\t\t\t\t{fw_ref} /* MobileVLCKit.xcframework */,\n\2", + text, + count=1, +) + +# Target build settings +text = text.replace( + "MARKETING_VERSION = 0.2.0;\n", + ( + "MARKETING_VERSION = 0.2.0;\n" + "\t\t\t\tFRAMEWORK_SEARCH_PATHS = (\n" + '\t\t\t\t\t"$(inherited)",\n' + '\t\t\t\t\t"$(PROJECT_DIR)/Vendor",\n' + "\t\t\t\t);\n" + "\t\t\t\tENABLE_BITCODE = NO;\n" + '\t\t\t\tOTHER_LDFLAGS = ("$(inherited)", "-ObjC");\n' + ), +) + +pbx_path.write_text(text) +print("Patched OK") +PY diff --git a/apps/ios/store/screenshots/1242x2688/01-home.png b/apps/ios/store/screenshots/1242x2688/01-home.png new file mode 100644 index 0000000..817bdd5 Binary files /dev/null and b/apps/ios/store/screenshots/1242x2688/01-home.png differ diff --git a/apps/ios/store/screenshots/1242x2688/02-detail.png b/apps/ios/store/screenshots/1242x2688/02-detail.png new file mode 100644 index 0000000..0cd8c4c Binary files /dev/null and b/apps/ios/store/screenshots/1242x2688/02-detail.png differ diff --git a/apps/ios/store/screenshots/1242x2688/03-livetv.png b/apps/ios/store/screenshots/1242x2688/03-livetv.png new file mode 100644 index 0000000..02b12ae Binary files /dev/null and b/apps/ios/store/screenshots/1242x2688/03-livetv.png differ diff --git a/apps/ios/store/screenshots/1242x2688/04-player.png b/apps/ios/store/screenshots/1242x2688/04-player.png new file mode 100644 index 0000000..4a46776 Binary files /dev/null and b/apps/ios/store/screenshots/1242x2688/04-player.png differ diff --git a/apps/ios/store/screenshots/1284x2778/01-home.png b/apps/ios/store/screenshots/1284x2778/01-home.png new file mode 100644 index 0000000..f4d8d86 Binary files /dev/null and b/apps/ios/store/screenshots/1284x2778/01-home.png differ diff --git a/apps/ios/store/screenshots/1284x2778/02-detail.png b/apps/ios/store/screenshots/1284x2778/02-detail.png new file mode 100644 index 0000000..4374673 Binary files /dev/null and b/apps/ios/store/screenshots/1284x2778/02-detail.png differ diff --git a/apps/ios/store/screenshots/1284x2778/03-livetv.png b/apps/ios/store/screenshots/1284x2778/03-livetv.png new file mode 100644 index 0000000..7d23d15 Binary files /dev/null and b/apps/ios/store/screenshots/1284x2778/03-livetv.png differ diff --git a/apps/ios/store/screenshots/1284x2778/04-player.png b/apps/ios/store/screenshots/1284x2778/04-player.png new file mode 100644 index 0000000..f66e5a3 Binary files /dev/null and b/apps/ios/store/screenshots/1284x2778/04-player.png differ