Add native VMC iOS client with VLC playback.

SwiftUI app against master API: link, profiles, browse, live TV, favorites sync, and TestFlight-ready assets.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-09-07 19:16:29 +02:00
parent 2eabd76d93
commit 252bce838b
41 changed files with 4984 additions and 0 deletions

5
apps/ios/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
# Large binary — install via scripts/fetch-vlckit.sh + link-vlckit.sh on Mac
Vendor/
*.xcworkspace
Pods/
Podfile.lock

15
apps/ios/Podfile Normal file
View file

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

34
apps/ios/README.md Normal file
View file

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

View file

@ -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 = "<group>"; };
C2EA13EA1EAB446784C29601 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = "<group>"; };
528F82E4F9DA437F8A507302 /* ApiClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiClient.swift; sourceTree = "<group>"; };
1C5B25BEDABD47DD88AA0AE8 /* SessionStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionStore.swift; sourceTree = "<group>"; };
B1D2E3F4A5B647C8D9E0F1A2 /* FavoritesStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesStore.swift; sourceTree = "<group>"; };
E44A28ED2FE842AEA848392F /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = "<group>"; };
3215184C370A49AB83E53332 /* McTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = McTheme.swift; sourceTree = "<group>"; };
8BE72D16CFAE415F9B52FE43 /* ProfileAvatars.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileAvatars.swift; sourceTree = "<group>"; };
ED163B837C404A1183E20226 /* VLCPlayerController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VLCPlayerController.swift; sourceTree = "<group>"; };
2BFA8FFCE77B497FBE503CA0 /* PlayerScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerScreen.swift; sourceTree = "<group>"; };
522137D80C53480E95FF600F /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = "<group>"; };
BF5E450343884C11B0920888 /* MainShell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainShell.swift; sourceTree = "<group>"; };
314B20E94E6B4034B1EF282B /* Components.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Components.swift; sourceTree = "<group>"; };
19304FA5C7624099B8E97E1F /* LinkScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkScreen.swift; sourceTree = "<group>"; };
EFD53AA4D5C9400383EE7DC7 /* ProfilesScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilesScreen.swift; sourceTree = "<group>"; };
5561954288524F7C9E41CA40 /* HomeScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreen.swift; sourceTree = "<group>"; };
E44909AE0F474047ADB06E27 /* BrowseScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseScreen.swift; sourceTree = "<group>"; };
F950D46F720C4C359184B2CF /* SearchScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchScreen.swift; sourceTree = "<group>"; };
F0A435950EF040DE83F30779 /* DetailScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailScreens.swift; sourceTree = "<group>"; };
98E3A2A592E64C998AB1B72A /* LiveTvScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTvScreen.swift; sourceTree = "<group>"; };
470B4DF416DE4B3FB7A60DDB /* SettingsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreen.swift; sourceTree = "<group>"; };
D4A1F82E6B9C4E7A8F1D3C50 /* QualityBadges.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QualityBadges.swift; sourceTree = "<group>"; };
6FA95D6A03BF48EA97553486 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
A22966A1CCBE44999F75FCA2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
BE257D7C209E4803903206A8 /* MobileVLCKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = MobileVLCKit.xcframework; path = Vendor/MobileVLCKit.xcframework; sourceTree = "<group>"; };
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 = "<group>";
};
AA621A418F3940A281D959B4 /* Products */ = {
isa = PBXGroup;
children = (
E3BC8A7BDE75425E84A174DE /* VMC.app */,
);
name = Products;
sourceTree = "<group>";
};
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 = "<group>";
};
8834A61BE0484DC5AB28E4D5 /* Data */ = {
isa = PBXGroup;
children = (
C2EA13EA1EAB446784C29601 /* Models.swift */,
528F82E4F9DA437F8A507302 /* ApiClient.swift */,
1C5B25BEDABD47DD88AA0AE8 /* SessionStore.swift */,
B1D2E3F4A5B647C8D9E0F1A2 /* FavoritesStore.swift */,
E44A28ED2FE842AEA848392F /* AppModel.swift */,
);
path = Data;
sourceTree = "<group>";
};
92370C3FECCC4F208F2DE9FF /* Theme */ = {
isa = PBXGroup;
children = (
3215184C370A49AB83E53332 /* McTheme.swift */,
8BE72D16CFAE415F9B52FE43 /* ProfileAvatars.swift */,
);
path = Theme;
sourceTree = "<group>";
};
F8B60A829DE04A098A56BD06 /* Playback */ = {
isa = PBXGroup;
children = (
ED163B837C404A1183E20226 /* VLCPlayerController.swift */,
2BFA8FFCE77B497FBE503CA0 /* PlayerScreen.swift */,
);
path = Playback;
sourceTree = "<group>";
};
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 = "<group>";
};
/* 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 */;
}

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 423 KiB

View file

@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "AppIcon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

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

View file

@ -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<T: Decodable>(_ 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<T: Decodable>(_ 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<T: Decodable>(_ 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
}
}

View file

@ -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<AnyCancellable>()
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
}
}

View file

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

View file

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

View file

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

60
apps/ios/VMC/Info.plist Normal file
View file

@ -0,0 +1,60 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>nl</string>
<key>CFBundleDisplayName</key>
<string>VMC</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
<key>NSAllowsArbitraryLoadsForMedia</key>
<true/>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSLocalNetworkUsageDescription</key>
<string>VMC speelt media af vanaf je Media Cluster op het lokale netwerk.</string>
<key>NSBonjourServices</key>
<array>
<string>_http._tcp</string>
<string>_https._tcp</string>
</array>
<key>UILaunchScreen</key>
<dict>
<key>UIColorName</key>
<string>AccentColor</string>
</dict>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>

View file

@ -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<Void, Never>?
@State private var hideTask: Task<Void, Never>?
@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
}
}
}

View file

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

View file

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

View file

@ -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 ?? "🙂"
}
}

View file

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

View file

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

View file

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

View file

@ -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<String>()
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) }
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

14
apps/ios/VMC/VMCApp.swift Normal file
View file

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

View file

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

View file

@ -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 = "<group>"; }};\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

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB