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 |