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>
5
apps/ios/.gitignore
vendored
Normal 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
|
|
@ -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
|
|
@ -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.
|
||||
396
apps/ios/VMC.xcodeproj/project.pbxproj
Normal 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 */;
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
BIN
apps/ios/VMC/Assets.xcassets/AppIcon.appiconset/AppIcon.png
Normal file
|
After Width: | Height: | Size: 423 KiB |
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AppIcon.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
6
apps/ios/VMC/Assets.xcassets/Contents.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "Vonas",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
303
apps/ios/VMC/Data/ApiClient.swift
Normal 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
|
||||
}
|
||||
}
|
||||
147
apps/ios/VMC/Data/AppModel.swift
Normal 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
|
||||
}
|
||||
}
|
||||
205
apps/ios/VMC/Data/FavoritesStore.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
232
apps/ios/VMC/Data/Models.swift
Normal 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
|
||||
}
|
||||
68
apps/ios/VMC/Data/SessionStore.swift
Normal 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
|
|
@ -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>
|
||||
481
apps/ios/VMC/Playback/PlayerScreen.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
261
apps/ios/VMC/Playback/VLCPlayerController.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
59
apps/ios/VMC/Theme/McTheme.swift
Normal 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())
|
||||
}
|
||||
}
|
||||
20
apps/ios/VMC/Theme/ProfileAvatars.swift
Normal 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 ?? "🙂"
|
||||
}
|
||||
}
|
||||
193
apps/ios/VMC/UI/BrowseScreen.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
172
apps/ios/VMC/UI/Components.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
413
apps/ios/VMC/UI/DetailScreens.swift
Normal 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
|
||||
}
|
||||
}
|
||||
251
apps/ios/VMC/UI/HomeScreen.swift
Normal 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) }
|
||||
}
|
||||
}
|
||||
116
apps/ios/VMC/UI/LinkScreen.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
206
apps/ios/VMC/UI/LiveTvScreen.swift
Normal 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
|
||||
}
|
||||
80
apps/ios/VMC/UI/MainShell.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
233
apps/ios/VMC/UI/ProfilesScreen.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
158
apps/ios/VMC/UI/QualityBadges.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
43
apps/ios/VMC/UI/RootView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
101
apps/ios/VMC/UI/SearchScreen.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
535
apps/ios/VMC/UI/SettingsScreen.swift
Normal 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
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
49
apps/ios/scripts/fetch-vlckit.sh
Normal 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
|
||||
94
apps/ios/scripts/link-vlckit.sh
Normal 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
|
||||
BIN
apps/ios/store/screenshots/1242x2688/01-home.png
Normal file
|
After Width: | Height: | Size: 122 KiB |
BIN
apps/ios/store/screenshots/1242x2688/02-detail.png
Normal file
|
After Width: | Height: | Size: 143 KiB |
BIN
apps/ios/store/screenshots/1242x2688/03-livetv.png
Normal file
|
After Width: | Height: | Size: 161 KiB |
BIN
apps/ios/store/screenshots/1242x2688/04-player.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
apps/ios/store/screenshots/1284x2778/01-home.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
apps/ios/store/screenshots/1284x2778/02-detail.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
apps/ios/store/screenshots/1284x2778/03-livetv.png
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
apps/ios/store/screenshots/1284x2778/04-player.png
Normal file
|
After Width: | Height: | Size: 33 KiB |