From ac951617cbe059a5936ddffdebf9630555f3407c Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Fri, 18 Sep 2026 23:34:42 +0200 Subject: [PATCH] Add Live Events across API/admin/Android TV, mobile ClearKey/DASH fixes, and tvOS app. Wire FAAB schedule-events into master-api and admin IPTV settings, add Android TV Events UI, fix mobile ClearKey/stream playback (DASH + bump 1.0.23), and scaffold the Apple TV (VMCTV) client with docs/scripts. --- apps/admin-ui/src/app/settings/page.tsx | 35 + apps/android-tv/app-mobile/build.gradle.kts | 5 +- .../mediacluster/tv/cast/CastTargetPicker.kt | 2 +- .../vonas/mediacluster/tv/data/ApiClient.kt | 70 +- .../mediacluster/tv/playback/PlayerFactory.kt | 211 ++++- .../mediacluster/tv/ui/LiveTvPlayerScreen.kt | 43 +- .../java/nl/vonas/mediacluster/tv/ui/McNav.kt | 16 +- .../nl/vonas/mediacluster/tv/ui/TvScreen.kt | 12 +- .../vonas/mediacluster/tv/data/ApiClient.kt | 148 ++++ .../mediacluster/tv/ui/EventPlayerScreen.kt | 141 +++ .../vonas/mediacluster/tv/ui/EventsScreen.kt | 812 +++++++++++++++++ .../java/nl/vonas/mediacluster/tv/ui/McNav.kt | 64 +- .../mediacluster/tv/ui/components/TopNav.kt | 1 + apps/ios/README.md | 27 +- apps/ios/VMCTV.xcodeproj/project.pbxproj | 418 +++++++++ .../AccentColor.colorset/Contents.json | 20 + .../Content.imageset/Back.png | Bin 0 -> 45740 bytes .../Content.imageset/Contents.json | 6 + .../Back.imagestacklayer/Contents.json | 7 + .../Contents.json | 8 + .../Content.imageset/Contents.json | 6 + .../Content.imageset/Front.png | Bin 0 -> 10013 bytes .../Front.imagestacklayer/Contents.json | 7 + .../Content.imageset/Contents.json | 6 + .../Content.imageset/Middle.png | Bin 0 -> 11586 bytes .../Middle.imagestacklayer/Contents.json | 7 + .../Content.imageset/Back.png | Bin 0 -> 9603 bytes .../Content.imageset/Back@2x.png | Bin 0 -> 27408 bytes .../Content.imageset/Contents.json | 7 + .../Back.imagestacklayer/Contents.json | 7 + .../App Icon.imagestack/Contents.json | 8 + .../Content.imageset/Contents.json | 7 + .../Content.imageset/Front.png | Bin 0 -> 2259 bytes .../Content.imageset/Front@2x.png | Bin 0 -> 5271 bytes .../Front.imagestacklayer/Contents.json | 7 + .../Content.imageset/Contents.json | 7 + .../Content.imageset/Middle.png | Bin 0 -> 2480 bytes .../Content.imageset/Middle@2x.png | Bin 0 -> 5954 bytes .../Middle.imagestacklayer/Contents.json | 7 + .../Contents.json | 9 + .../Contents.json | 7 + .../TopShelfWide.png | Bin 0 -> 108188 bytes .../TopShelfWide@2x.png | Bin 0 -> 243151 bytes .../Top Shelf Image.imageset/Contents.json | 7 + .../Top Shelf Image.imageset/TopShelf.png | Bin 0 -> 104304 bytes .../Top Shelf Image.imageset/TopShelf@2x.png | Bin 0 -> 242395 bytes apps/ios/VMCTV/Assets.xcassets/Contents.json | 6 + apps/ios/VMCTV/Data/ApiClient.swift | 394 +++++++++ apps/ios/VMCTV/Data/AppModel.swift | 163 ++++ apps/ios/VMCTV/Data/FavoritesStore.swift | 143 +++ apps/ios/VMCTV/Data/Models.swift | 347 ++++++++ apps/ios/VMCTV/Data/SessionStore.swift | 127 +++ apps/ios/VMCTV/Info.plist | 48 + apps/ios/VMCTV/Playback/TVPlayerView.swift | 823 ++++++++++++++++++ .../VMCTV/Playback/VLCPlayerController.swift | 441 ++++++++++ apps/ios/VMCTV/README.md | 35 + apps/ios/VMCTV/Theme/ProfileAvatars.swift | 20 + apps/ios/VMCTV/Theme/TvTheme.swift | 127 +++ apps/ios/VMCTV/UI/AuthViews.swift | 347 ++++++++ apps/ios/VMCTV/UI/BrowseView.swift | 237 +++++ apps/ios/VMCTV/UI/Components.swift | 271 ++++++ apps/ios/VMCTV/UI/DetailViews.swift | 502 +++++++++++ apps/ios/VMCTV/UI/HomeView.swift | 265 ++++++ apps/ios/VMCTV/UI/LiveTvView.swift | 660 ++++++++++++++ apps/ios/VMCTV/UI/MainHub.swift | 40 + apps/ios/VMCTV/UI/ManageProfilesView.swift | 377 ++++++++ apps/ios/VMCTV/UI/ProfilesView.swift | 232 +++++ apps/ios/VMCTV/UI/RootView.swift | 68 ++ apps/ios/VMCTV/UI/SearchView.swift | 97 +++ apps/ios/VMCTV/UI/SettingsView.swift | 399 +++++++++ apps/ios/VMCTV/UI/TopNav.swift | 170 ++++ apps/ios/VMCTV/UI/WatchlistView.swift | 75 ++ apps/ios/VMCTV/VMCTVApp.swift | 24 + apps/ios/scripts/fetch-tvvlckit.sh | 46 + apps/ios/scripts/link-tvvlckit.sh | 20 + apps/master-api/src/settings/iptv.ts | 41 + apps/master-api/src/settings/routes.ts | 1 + apps/master-api/src/viewer/routes.ts | 29 +- apps/master-api/src/viewer/schedule-events.ts | 412 +++++++++ 79 files changed, 9058 insertions(+), 74 deletions(-) create mode 100644 apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/EventPlayerScreen.kt create mode 100644 apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/EventsScreen.kt create mode 100644 apps/ios/VMCTV.xcodeproj/project.pbxproj create mode 100644 apps/ios/VMCTV/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Back.png create mode 100644 apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Contents.json create mode 100644 apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Contents.json create mode 100644 apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Contents.json create mode 100644 apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Contents.json create mode 100644 apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Front.png create mode 100644 apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Contents.json create mode 100644 apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json create mode 100644 apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Content.imageset/Middle.png create mode 100644 apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Contents.json create mode 100644 apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Back.png create mode 100644 apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Back@2x.png create mode 100644 apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Contents.json create mode 100644 apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Contents.json create mode 100644 apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Contents.json create mode 100644 apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Contents.json create mode 100644 apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Front.png create mode 100644 apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Front@2x.png create mode 100644 apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Contents.json create mode 100644 apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json create mode 100644 apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Middle.png create mode 100644 apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Middle@2x.png create mode 100644 apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Contents.json create mode 100644 apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Contents.json create mode 100644 apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Contents.json create mode 100644 apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/TopShelfWide.png create mode 100644 apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/TopShelfWide@2x.png create mode 100644 apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Contents.json create mode 100644 apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/TopShelf.png create mode 100644 apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/TopShelf@2x.png create mode 100644 apps/ios/VMCTV/Assets.xcassets/Contents.json create mode 100644 apps/ios/VMCTV/Data/ApiClient.swift create mode 100644 apps/ios/VMCTV/Data/AppModel.swift create mode 100644 apps/ios/VMCTV/Data/FavoritesStore.swift create mode 100644 apps/ios/VMCTV/Data/Models.swift create mode 100644 apps/ios/VMCTV/Data/SessionStore.swift create mode 100644 apps/ios/VMCTV/Info.plist create mode 100644 apps/ios/VMCTV/Playback/TVPlayerView.swift create mode 100644 apps/ios/VMCTV/Playback/VLCPlayerController.swift create mode 100644 apps/ios/VMCTV/README.md create mode 100644 apps/ios/VMCTV/Theme/ProfileAvatars.swift create mode 100644 apps/ios/VMCTV/Theme/TvTheme.swift create mode 100644 apps/ios/VMCTV/UI/AuthViews.swift create mode 100644 apps/ios/VMCTV/UI/BrowseView.swift create mode 100644 apps/ios/VMCTV/UI/Components.swift create mode 100644 apps/ios/VMCTV/UI/DetailViews.swift create mode 100644 apps/ios/VMCTV/UI/HomeView.swift create mode 100644 apps/ios/VMCTV/UI/LiveTvView.swift create mode 100644 apps/ios/VMCTV/UI/MainHub.swift create mode 100644 apps/ios/VMCTV/UI/ManageProfilesView.swift create mode 100644 apps/ios/VMCTV/UI/ProfilesView.swift create mode 100644 apps/ios/VMCTV/UI/RootView.swift create mode 100644 apps/ios/VMCTV/UI/SearchView.swift create mode 100644 apps/ios/VMCTV/UI/SettingsView.swift create mode 100644 apps/ios/VMCTV/UI/TopNav.swift create mode 100644 apps/ios/VMCTV/UI/WatchlistView.swift create mode 100644 apps/ios/VMCTV/VMCTVApp.swift create mode 100644 apps/ios/scripts/fetch-tvvlckit.sh create mode 100644 apps/ios/scripts/link-tvvlckit.sh create mode 100644 apps/master-api/src/viewer/schedule-events.ts diff --git a/apps/admin-ui/src/app/settings/page.tsx b/apps/admin-ui/src/app/settings/page.tsx index 7c3a07e..4d946ff 100644 --- a/apps/admin-ui/src/app/settings/page.tsx +++ b/apps/admin-ui/src/app/settings/page.tsx @@ -161,6 +161,9 @@ export default function SettingsPage() { const [gpErr, setGpErr] = useState(null); const [tvEpgUrlsText, setTvEpgUrlsText] = useState(""); + const [tvLiveEventsUrl, setTvLiveEventsUrl] = useState( + "http://epg.faabserver.nl/api/export/live-events?horizon_days=3" + ); const [tvShowAlle, setTvShowAlle] = useState(true); const [tvCategories, setTvCategories] = useState< Array<{ id: string; name: string; channelCount: number }> @@ -254,6 +257,11 @@ export default function SettingsPage() { if (s) { setTvEpgUrlsText(Array.isArray(s.epgXmlUrls) ? s.epgXmlUrls.join("\n") : ""); setTvShowAlle(s.showAlleZenders !== false); + setTvLiveEventsUrl( + typeof s.liveEventsUrl === "string" && s.liveEventsUrl.trim() + ? s.liveEventsUrl + : "http://epg.faabserver.nl/api/export/live-events?horizon_days=3" + ); } setTvCategories(Array.isArray(d.categories) ? d.categories : []); }) @@ -559,6 +567,7 @@ export default function SettingsPage() { epgXmlUrls, categoryOrder: tvCategories.map((c) => c.id), showAlleZenders: tvShowAlle, + liveEventsUrl: tvLiveEventsUrl.trim(), }), }); const data = await res.json().catch(() => ({})); @@ -572,6 +581,11 @@ export default function SettingsPage() { Array.isArray(data.settings.epgXmlUrls) ? data.settings.epgXmlUrls.join("\n") : "" ); setTvShowAlle(data.settings.showAlleZenders !== false); + setTvLiveEventsUrl( + typeof data.settings.liveEventsUrl === "string" && data.settings.liveEventsUrl.trim() + ? data.settings.liveEventsUrl + : "" + ); } if (Array.isArray(data.categories)) setTvCategories(data.categories); } finally { @@ -914,6 +928,27 @@ export default function SettingsPage() { +
+

+ Live Events-feed +

+

+ JSON-export (FAAB) voor de Events-tab in Android TV. Leeg laten schakelt de tab uit. + Default: FAAB live-events met 3 dagen horizon. +

+ +
+

Categorie-volgorde diff --git a/apps/android-tv/app-mobile/build.gradle.kts b/apps/android-tv/app-mobile/build.gradle.kts index 442c17b..92ae9aa 100644 --- a/apps/android-tv/app-mobile/build.gradle.kts +++ b/apps/android-tv/app-mobile/build.gradle.kts @@ -20,8 +20,8 @@ android { applicationId = "nl.vonas.mediacluster" minSdk = 24 targetSdk = 36 - versionCode = 23 - versionName = "1.0.22" + versionCode = 24 + versionName = "1.0.23" buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"") ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a") @@ -82,6 +82,7 @@ dependencies { val media3 = "1.10.1" implementation("androidx.media3:media3-exoplayer:$media3") implementation("androidx.media3:media3-exoplayer-hls:$media3") + implementation("androidx.media3:media3-exoplayer-dash:$media3") implementation("androidx.media3:media3-ui:$media3") implementation("androidx.media3:media3-datasource-okhttp:$media3") implementation("io.github.anilbeesetti:nextlib-media3ext:1.10.1-0.13.0") diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/cast/CastTargetPicker.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/cast/CastTargetPicker.kt index e1bd4de..17f080b 100644 --- a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/cast/CastTargetPicker.kt +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/cast/CastTargetPicker.kt @@ -50,7 +50,7 @@ sealed class CastTarget { data class CastPlayRequest( val mediaFileId: String? = null, - val streamId: Int? = null, + val streamId: String? = null, val mediaType: String = "MOVIE", val movieId: String? = null, val episodeId: String? = null, diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt index 6858a63..b8722a1 100644 --- a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt @@ -514,7 +514,7 @@ class ApiClient( val c = arr.getJSONObject(i) add( IptvChannel( - streamId = c.getInt("streamId"), + streamId = c.optStreamIdString(), name = c.getString("name"), logoUrl = c.optClean("logoUrl"), number = c.optInt("number").takeIf { c.has("number") && !c.isNull("number") }, @@ -527,26 +527,45 @@ class ApiClient( } } - suspend fun iptvPlayUrl(streamId: Int): IptvPlayInfo = withContext(Dispatchers.IO) { - val req = authed("$baseUrl/api/v1/client/iptv/channels/$streamId/play").get().build() + suspend fun iptvPlayUrl(streamId: String): IptvPlayInfo = withContext(Dispatchers.IO) { + val enc = android.net.Uri.encode(streamId) + val req = authed("$baseUrl/api/v1/client/iptv/channels/$enc/play").get().build() parseObject(execute(req)) { o -> + val drmObj = o.optJSONObject("drm") + val drm = if (drmObj != null) { + val keysArr = drmObj.optJSONArray("keys") ?: JSONArray() + val keys = buildList { + for (i in 0 until keysArr.length()) { + val k = keysArr.optJSONObject(i) ?: continue + val kid = k.optString("kid").ifBlank { null } ?: continue + val key = k.optString("key").ifBlank { null } ?: continue + add(ClearKeyEntry(kid = kid, key = key)) + } + } + DrmInfo( + type = drmObj.optString("type").ifBlank { "clearkey" }, + keys = keys, + ).takeIf { it.keys.isNotEmpty() } + } else null IptvPlayInfo( - streamId = o.getInt("streamId"), + streamId = o.optStreamIdString(), name = o.getString("name"), logoUrl = o.optString("logoUrl").ifBlank { null }, streamUrl = o.getString("streamUrl"), format = o.optString("format").ifBlank { "hls" }, fallbackStreamUrl = o.optString("fallbackStreamUrl").ifBlank { null }, fallbackFormat = o.optString("fallbackFormat").ifBlank { null }, + drm = drm, ) } } - suspend fun iptvEpg(streamId: Int): IptvEpg = withContext(Dispatchers.IO) { - val req = authed("$baseUrl/api/v1/client/iptv/channels/$streamId/epg").get().build() + suspend fun iptvEpg(streamId: String): IptvEpg = withContext(Dispatchers.IO) { + val enc = android.net.Uri.encode(streamId) + val req = authed("$baseUrl/api/v1/client/iptv/channels/$enc/epg").get().build() parseObject(execute(req)) { o -> IptvEpg( - streamId = o.getInt("streamId"), + streamId = o.optStreamIdString(), channelName = o.getString("channelName"), logoUrl = o.optString("logoUrl").ifBlank { null }, now = o.optJSONObject("now")?.toEpgProgram(), @@ -558,13 +577,13 @@ class ApiClient( /** Vul ontbrekende now-titels aan; [onBatch] voor live UI-updates. */ suspend fun fetchMissingNowTitles( channels: List, - onBatch: ((Map) -> Unit)? = null, - ): Map { + onBatch: ((Map) -> Unit)? = null, + ): Map { fun clean(title: String?) = title?.takeIf { it.isNotBlank() && !it.equals("null", ignoreCase = true) } - val result = linkedMapOf() + val result = linkedMapOf() channels.forEach { ch -> clean(ch.nowTitle)?.let { result[ch.streamId] = it } } @@ -579,7 +598,7 @@ class ApiClient( coroutineScope { missing.chunked(12).forEach { batch -> - val chunk = linkedMapOf() + val chunk = linkedMapOf() batch.map { ch -> async(Dispatchers.IO) { runCatching { @@ -654,7 +673,7 @@ class ApiClient( mediaFileId = s?.optClean("mediaFileId"), streamId = when { s == null || !s.has("streamId") || s.isNull("streamId") -> null - else -> s.optInt("streamId").takeIf { it > 0 } + else -> s.optStreamIdString().takeIf { it.isNotBlank() } }, mediaType = s?.optClean("mediaType"), ) @@ -670,6 +689,14 @@ class ApiClient( ) } + private fun JSONObject.optStreamIdString(key: String = "streamId"): String { + if (!has(key) || isNull(key)) return "" + return when (val v = opt(key)) { + is Number -> v.toLong().toString() + else -> optString(key).trim() + } + } + private fun authed(url: String): Request.Builder { val b = Request.Builder().url(url) val t = token @@ -776,7 +803,7 @@ data class CastDeviceStatus( val positionMs: Long, val durationMs: Long, val mediaFileId: String?, - val streamId: Int?, + val streamId: String?, val mediaType: String?, ) @@ -878,7 +905,7 @@ data class IptvCategory( ) data class IptvChannel( - val streamId: Int, + val streamId: String, val name: String, val logoUrl: String? = null, val number: Int? = null, @@ -886,14 +913,25 @@ data class IptvChannel( val nowTitle: String? = null, ) +data class ClearKeyEntry( + val kid: String, + val key: String, +) + +data class DrmInfo( + val type: String, + val keys: List, +) + data class IptvPlayInfo( - val streamId: Int, + val streamId: String, val name: String, val logoUrl: String? = null, val streamUrl: String, val format: String, val fallbackStreamUrl: String? = null, val fallbackFormat: String? = null, + val drm: DrmInfo? = null, ) data class EpgProgram( @@ -903,7 +941,7 @@ data class EpgProgram( ) data class IptvEpg( - val streamId: Int, + val streamId: String, val channelName: String, val logoUrl: String? = null, val now: EpgProgram? = null, diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/playback/PlayerFactory.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/playback/PlayerFactory.kt index a56154f..4406de4 100644 --- a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/playback/PlayerFactory.kt +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/playback/PlayerFactory.kt @@ -2,9 +2,13 @@ package nl.vonas.mediacluster.tv.playback import android.content.Context import android.os.Handler +import android.os.Looper +import android.util.Log import androidx.annotation.OptIn import androidx.media3.common.AudioAttributes import androidx.media3.common.C +import androidx.media3.common.DrmInitData +import androidx.media3.common.Format import androidx.media3.common.MediaItem import androidx.media3.common.MimeTypes import androidx.media3.common.TrackSelectionOverride @@ -14,17 +18,31 @@ import androidx.media3.datasource.okhttp.OkHttpDataSource import androidx.media3.exoplayer.DefaultLoadControl import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.Renderer +import androidx.media3.exoplayer.analytics.PlayerId import androidx.media3.exoplayer.audio.AudioRendererEventListener import androidx.media3.exoplayer.audio.AudioSink +import androidx.media3.exoplayer.drm.DefaultDrmSessionManager +import androidx.media3.exoplayer.drm.DefaultDrmSessionManagerProvider +import androidx.media3.exoplayer.drm.DrmSession +import androidx.media3.exoplayer.drm.DrmSessionEventListener +import androidx.media3.exoplayer.drm.DrmSessionManager +import androidx.media3.exoplayer.drm.FrameworkMediaDrm +import androidx.media3.exoplayer.drm.LocalMediaDrmCallback import androidx.media3.exoplayer.mediacodec.MediaCodecSelector import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.exoplayer.video.VideoRendererEventListener +import androidx.media3.extractor.mp4.PsshAtomUtil import io.github.anilbeesetti.nextlib.media3ext.ffdecoder.NextRenderersFactory import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER +import nl.vonas.mediacluster.tv.data.ClearKeyEntry +import nl.vonas.mediacluster.tv.data.DrmInfo import okhttp3.OkHttpClient +import java.nio.ByteBuffer +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit /** @@ -180,12 +198,12 @@ private val liveOkHttp: OkHttpClient by lazy { .build() } -/** Live IPTV: TS-first, kleine buffer, lage latency. */ +/** Live IPTV: TS/HLS/DASH. ClearKey-license per mediaId (LocalMediaDrmCallback). */ @OptIn(UnstableApi::class) fun buildLiveTvPlayer(context: Context): ExoPlayer { val appContext = context.applicationContext val dataSourceFactory = OkHttpDataSource.Factory(liveOkHttp) - .setUserAgent("VMC-AndroidTV") + .setUserAgent("VMC-Android") val trackSelector = DefaultTrackSelector(appContext).apply { parameters = buildUponParameters() @@ -211,9 +229,15 @@ fun buildLiveTvPlayer(context: Context): ExoPlayer { .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE) .build() + val fallbackDrmProvider = DefaultDrmSessionManagerProvider() + val mediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory) + .setDrmSessionManagerProvider { mediaItem -> + clearKeyDrmSessionManagerFor(mediaItem) ?: fallbackDrmProvider.get(mediaItem) + } + return ExoPlayer.Builder(appContext, renderersFactory) .setTrackSelector(trackSelector) - .setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory)) + .setMediaSourceFactory(mediaSourceFactory) .setLoadControl(loadControl) .setAudioAttributes(audioAttributes, /* handleAudioFocus = */ true) .setHandleAudioBecomingNoisy(true) @@ -222,10 +246,101 @@ fun buildLiveTvPlayer(context: Context): ExoPlayer { .also { it.volume = 1f } } -/** TS = geen live-offset (laagste latency). HLS-fallback ~2s achter live-rand. */ -fun liveMediaItem(url: String, format: String): MediaItem { - val isHls = format.equals("hls", ignoreCase = true) || url.contains("m3u8", ignoreCase = true) - val mime = if (isHls) MimeTypes.APPLICATION_M3U8 else MimeTypes.VIDEO_MP2T +/** ClearKey license + KIDs per MediaItem.mediaId. */ +private data class ClearKeyMaterial( + val licenseJson: String, + val keyIds: List, +) + +private val clearKeyLicenses = ConcurrentHashMap() +private const val TAG = "VmcLiveDrm" + +@OptIn(UnstableApi::class) +private fun clearKeyDrmSessionManagerFor(mediaItem: MediaItem): DrmSessionManager? { + val mediaId = mediaItem.mediaId?.takeIf { it.isNotBlank() } ?: return null + val material = clearKeyLicenses[mediaId] ?: return null + Log.i(TAG, "ClearKey DRM session for mediaId=$mediaId kids=${material.keyIds.size}") + val callback = LocalMediaDrmCallback(material.licenseJson.toByteArray(Charsets.UTF_8)) + val base = DefaultDrmSessionManager.Builder() + .setUuidAndExoMediaDrmProvider(C.CLEARKEY_UUID, FrameworkMediaDrm.DEFAULT_PROVIDER) + .setMultiSession(true) + .setPlayClearSamplesWithoutKeys(true) + .build(callback) + return InjectingClearKeyDrmSessionManager(base, material.keyIds) +} + +@OptIn(UnstableApi::class) +private class InjectingClearKeyDrmSessionManager( + private val wrapped: DefaultDrmSessionManager, + private val keyIds: List, +) : DrmSessionManager { + override fun prepare() = wrapped.prepare() + override fun release() = wrapped.release() + + override fun setPlayer(playbackLooper: Looper, playerId: PlayerId) { + wrapped.setPlayer(playbackLooper, playerId) + } + + override fun acquireSession( + eventDispatcher: DrmSessionEventListener.EventDispatcher?, + format: Format, + ): DrmSession? { + return wrapped.acquireSession(eventDispatcher, formatWithClearKeyScheme(format)) + } + + override fun getCryptoType(format: Format): Int { + val withScheme = formatWithClearKeyScheme(format) + val type = wrapped.getCryptoType(withScheme) + return if (type == C.CRYPTO_TYPE_NONE && keyIds.isNotEmpty()) { + C.CRYPTO_TYPE_FRAMEWORK + } else { + type + } + } + + private fun formatWithClearKeyScheme(format: Format): Format { + val existing = format.drmInitData + val hasClearKey = existing != null && (0 until existing.schemeDataCount).any { + existing.get(it).matches(C.CLEARKEY_UUID) + } + if (hasClearKey || keyIds.isEmpty()) return format + + val keyUuids = keyIds.map { bytesToUuid(it) }.toTypedArray() + val pssh = PsshAtomUtil.buildPsshAtom(C.CLEARKEY_UUID, keyUuids, /* data= */ null) + val schemeData = DrmInitData.SchemeData( + C.CLEARKEY_UUID, + MimeTypes.VIDEO_MP4, + pssh, + ) + Log.i(TAG, "Injecting ClearKey PSSH with ${keyIds.size} key id(s)") + return format.buildUpon() + .setDrmInitData(DrmInitData(schemeData)) + .build() + } +} + +private fun bytesToUuid(bytes: ByteArray): UUID { + val buf = ByteBuffer.wrap(bytes) + return UUID(buf.long, buf.long) +} + +/** TS / HLS / DASH(+ClearKey). */ +@OptIn(UnstableApi::class) +fun liveMediaItem( + url: String, + format: String, + drm: DrmInfo? = null, +): MediaItem { + val isDash = format.equals("dash", ignoreCase = true) || + url.contains(".mpd", ignoreCase = true) + val isHls = !isDash && ( + format.equals("hls", ignoreCase = true) || url.contains("m3u8", ignoreCase = true) + ) + val mime = when { + isDash -> MimeTypes.APPLICATION_MPD + isHls -> MimeTypes.APPLICATION_M3U8 + else -> MimeTypes.VIDEO_MP2T + } val builder = MediaItem.Builder().setUri(url).setMimeType(mime) if (isHls) { builder.setLiveConfiguration( @@ -236,9 +351,91 @@ fun liveMediaItem(url: String, format: String): MediaItem { .build(), ) } + if (isDash) { + builder.setLiveConfiguration( + MediaItem.LiveConfiguration.Builder() + .setTargetOffsetMs(3_000) + .setMinOffsetMs(1_500) + .setMaxOffsetMs(8_000) + .build(), + ) + } + val clearKeys = drm?.takeIf { it.type.equals("clearkey", ignoreCase = true) }?.keys.orEmpty() + if (clearKeys.isNotEmpty()) { + val mediaId = "ck-${UUID.randomUUID()}" + val keyIds = buildList { + for (k in clearKeys) { + val kid = hexToBytes(k.kid) + add(kid) + val swapped = swapUuidEndianness(kid) + if (!swapped.contentEquals(kid)) add(swapped) + } + } + val licenseJson = buildClearKeyLicenseJson(clearKeys) + clearKeyLicenses[mediaId] = ClearKeyMaterial(licenseJson, keyIds) + if (clearKeyLicenses.size > 32) { + clearKeyLicenses.keys.take(clearKeyLicenses.size - 24).forEach { clearKeyLicenses.remove(it) } + } + Log.i(TAG, "ClearKey mediaItem keys=${clearKeys.size} url=${url.take(96)}") + builder + .setMediaId(mediaId) + .setDrmConfiguration( + MediaItem.DrmConfiguration.Builder(C.CLEARKEY_UUID).build(), + ) + } return builder.build() } +private fun buildClearKeyLicenseJson(keys: List): String { + val arr = org.json.JSONArray() + for (k in keys) { + val kidBytes = hexToBytes(k.kid) + val keyBytes = hexToBytes(k.key) + val kidNormal = bytesToBase64Url(kidBytes) + val kidSwapped = bytesToBase64Url(swapUuidEndianness(kidBytes)) + val keyB64 = bytesToBase64Url(keyBytes) + arr.put(org.json.JSONObject().put("kty", "oct").put("kid", kidNormal).put("k", keyB64)) + if (kidSwapped != kidNormal) { + arr.put(org.json.JSONObject().put("kty", "oct").put("kid", kidSwapped).put("k", keyB64)) + } + } + return org.json.JSONObject() + .put("keys", arr) + .put("type", "temporary") + .toString() +} + +private fun hexToBytes(hex: String): ByteArray { + val clean = hex.trim() + .removePrefix("0x") + .removePrefix("0X") + .replace("-", "") + .replace(Regex("[\\s:]"), "") + .lowercase() + require(clean.length == 32 && clean.all { it in "0123456789abcdef" }) { + "ongeldige ClearKey hex (verwacht 32 tekens): ${hex.take(20)}" + } + return ByteArray(16) { i -> + clean.substring(i * 2, i * 2 + 2).toInt(16).toByte() + } +} + +private fun swapUuidEndianness(b: ByteArray): ByteArray { + require(b.size == 16) + return byteArrayOf( + b[3], b[2], b[1], b[0], + b[5], b[4], + b[7], b[6], + b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15], + ) +} + +private fun bytesToBase64Url(bytes: ByteArray): String = + android.util.Base64.encodeToString( + bytes, + android.util.Base64.URL_SAFE or android.util.Base64.NO_WRAP or android.util.Base64.NO_PADDING, + ) + data class PlayerTrack(val groupIndex: Int, val trackIndex: Int, val label: String, val type: Int) fun ExoPlayer.listTracks(type: Int, embeddedHints: List = emptyList()): List { diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/LiveTvPlayerScreen.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/LiveTvPlayerScreen.kt index 1c90578..c6207c6 100644 --- a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/LiveTvPlayerScreen.kt +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/LiveTvPlayerScreen.kt @@ -82,7 +82,7 @@ import nl.vonas.mediacluster.tv.ui.theme.McColors private data class MenuCategoryCache( val channels: List, - val epgTitles: Map, + val epgTitles: Map, val loadedAt: Long, ) @@ -92,10 +92,10 @@ private const val MENU_CACHE_TTL_MS = 90_000L @Composable fun LiveTvPlayerScreen( api: ApiClient, - streamId: Int, + streamId: String, categoryId: String?, onCategoryChange: (String) -> Unit, - onStreamChange: (Int) -> Unit, + onStreamChange: (String) -> Unit, onBack: () -> Unit, onOpenVmcRemote: (deviceId: String, deviceName: String) -> Unit = { _, _ -> }, ) { @@ -105,7 +105,7 @@ fun LiveTvPlayerScreen( val menuScope = rememberCoroutineScope() RememberPlayerImmersiveMode() - var currentStreamId by remember { mutableIntStateOf(streamId) } + var currentStreamId by remember { mutableStateOf(streamId) } var loading by remember { mutableStateOf(true) } var switching by remember { mutableStateOf(false) } var error by remember { mutableStateOf(null) } @@ -128,17 +128,17 @@ fun LiveTvPlayerScreen( var lastCategoryShiftAt by remember { mutableLongStateOf(0L) } var menuLoadGen by remember { mutableIntStateOf(0) } val menuCache = remember { mutableStateMapOf() } - val menuEpgNow = remember { mutableStateMapOf() } + val menuEpgNow = remember { mutableStateMapOf() } val menuListState = rememberLazyListState() - val menuChannelFocus = remember { mutableStateMapOf() } + val menuChannelFocus = remember { mutableStateMapOf() } - fun startStream(url: String, format: String) { - player.setMediaItem(liveMediaItem(url, format)) + fun startStream(url: String, format: String, drm: nl.vonas.mediacluster.tv.data.DrmInfo? = null) { + player.setMediaItem(liveMediaItem(url, format, drm)) player.prepare() player.playWhenReady = true } - suspend fun loadAndPlay(streamIdToPlay: Int, isSwitch: Boolean) { + suspend fun loadAndPlay(streamIdToPlay: String, isSwitch: Boolean) { if (isSwitch) switching = true else loading = true error = null triedFallback = false @@ -147,17 +147,24 @@ fun LiveTvPlayerScreen( channelName = play.name logoUrl = play.logoUrl streamUrl = play.streamUrl + val isDash = play.format.equals("dash", ignoreCase = true) val tsUrl = when { + isDash -> null play.format.equals("ts", ignoreCase = true) -> play.streamUrl play.fallbackFormat?.equals("ts", ignoreCase = true) == true -> play.fallbackStreamUrl else -> null } val hlsUrl = when { + isDash -> null play.format.equals("hls", ignoreCase = true) -> play.streamUrl play.fallbackFormat?.equals("hls", ignoreCase = true) == true -> play.fallbackStreamUrl else -> null } - if (!tsUrl.isNullOrBlank()) { + if (isDash) { + fallbackUrl = null + fallbackFormat = null + startStream(play.streamUrl, play.format, play.drm) + } else if (!tsUrl.isNullOrBlank()) { fallbackUrl = hlsUrl ?: play.fallbackStreamUrl fallbackFormat = (hlsUrl ?: play.fallbackStreamUrl)?.let { if (it.contains("m3u8", true)) "hls" else play.fallbackFormat @@ -166,7 +173,7 @@ fun LiveTvPlayerScreen( } else { fallbackUrl = play.fallbackStreamUrl fallbackFormat = play.fallbackFormat - startStream(play.streamUrl, play.format) + startStream(play.streamUrl, play.format, play.drm) } onStreamChange(streamIdToPlay) } catch (e: Exception) { @@ -202,7 +209,7 @@ fun LiveTvPlayerScreen( } } - fun applyMenuEpg(titles: Map) { + fun applyMenuEpg(titles: Map) { for ((id, title) in titles) { menuEpgNow[id] = title } @@ -219,7 +226,7 @@ fun LiveTvPlayerScreen( api.iptvChannels(includeEpg = true) } if (gen != menuLoadGen) return - val titles = linkedMapOf() + val titles = linkedMapOf() withEpg.forEach { ch -> ch.nowTitle?.let { titles[ch.streamId] = it } } applyMenuEpg(titles) menuCache[key] = MenuCategoryCache(withEpg, titles, System.currentTimeMillis()) @@ -353,7 +360,7 @@ fun LiveTvPlayerScreen( onBack() } - fun selectChannel(newStreamId: Int) { + fun selectChannel(newStreamId: String) { if (newStreamId == currentStreamId) return currentStreamId = newStreamId infoOverlay = true @@ -628,13 +635,13 @@ private fun LiveChannelSwitcher( categoryIndex: Int, categoryCount: Int, channels: List, - epgNow: Map, - currentStreamId: Int, + epgNow: Map, + currentStreamId: String, refreshing: Boolean, switching: Boolean, listState: androidx.compose.foundation.lazy.LazyListState, - channelFocus: MutableMap, - onSelect: (Int) -> Unit, + channelFocus: MutableMap, + onSelect: (String) -> Unit, onCategoryShift: (Int) -> Unit, modifier: Modifier = Modifier, ) { diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt index e0d0b52..1c640c9 100644 --- a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt @@ -114,7 +114,7 @@ fun McNav( // Blijft behouden als main uit compositie gaat (bijv. live player) var hubTabName by rememberSaveable { mutableStateOf(MainTab.Home.name) } var tvCategoryId by rememberSaveable { mutableStateOf(null) } - var tvLastStreamId by rememberSaveable { mutableStateOf(null) } + var tvLastStreamId by rememberSaveable { mutableStateOf(null) } var tvFocusNonce by remember { mutableIntStateOf(0) } val hubTab = remember(hubTabName) { MainTab.entries.firstOrNull { it.name == hubTabName } ?: MainTab.Home @@ -284,7 +284,7 @@ fun McNav( onPlayLive = { streamId -> tvLastStreamId = streamId if (hubTabName != MainTab.Tv.name) hubTabName = MainTab.Tv.name - nav.navigate("live/$streamId") + nav.navigate("live/${android.net.Uri.encode(streamId)}") }, onSwitchProfile = { scope.launch { @@ -389,9 +389,11 @@ fun McNav( } composable( "live/{streamId}", - arguments = listOf(navArgument("streamId") { type = NavType.IntType }), + arguments = listOf(navArgument("streamId") { type = NavType.StringType }), ) { entry -> - val streamId = entry.arguments?.getInt("streamId") ?: return@composable + val streamId = entry.arguments?.getString("streamId") + ?.let { android.net.Uri.decode(it) } + ?: return@composable DisposableEffect(Unit) { onDispose { tvFocusNonce++ } } @@ -469,8 +471,8 @@ private fun MainHub( onTabChange: (MainTab) -> Unit, tvCategoryId: String?, onTvCategoryChange: (String?) -> Unit, - tvLastStreamId: Int?, - onTvLastStreamChange: (Int?) -> Unit, + tvLastStreamId: String?, + onTvLastStreamChange: (String?) -> Unit, tvFocusNonce: Int, onTvFocusContent: () -> Unit, profileAvatarId: String?, @@ -483,7 +485,7 @@ private fun MainHub( onOpenSeries: (String) -> Unit, onBrowse: (catalogId: String, title: String) -> Unit, onPlay: (mediaFileId: String, movieId: String?, episodeId: String?, seriesId: String?, type: String, startMs: Long) -> Unit, - onPlayLive: (Int) -> Unit, + onPlayLive: (String) -> Unit, onSwitchProfile: () -> Unit, onManageProfiles: () -> Unit, onManageDevices: () -> Unit, diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/TvScreen.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/TvScreen.kt index 3dee137..6fa2d05 100644 --- a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/TvScreen.kt +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/TvScreen.kt @@ -72,11 +72,11 @@ fun TvScreen( api: ApiClient, selectedCategoryId: String?, onCategoryChange: (String?) -> Unit, - lastStreamId: Int? = null, - onLastStreamChange: (Int?) -> Unit = {}, + lastStreamId: String? = null, + onLastStreamChange: (String?) -> Unit = {}, focusContentNonce: Int = 0, onAtTopBack: () -> Unit, - onPlayChannel: (streamId: Int) -> Unit, + onPlayChannel: (streamId: String) -> Unit, onContentFocused: () -> Unit = {}, ) { var categories by remember { mutableStateOf>(emptyList()) } @@ -85,13 +85,13 @@ fun TvScreen( var loadingChannels by remember { mutableStateOf(false) } var error by remember { mutableStateOf(null) } var selectedEpg by remember { mutableStateOf(null) } - var selectedStreamId by remember { mutableStateOf(null) } + var selectedStreamId by remember { mutableStateOf(null) } var epgJob by remember { mutableStateOf(null) } val channelListState = rememberLazyListState() val categoryRowState = rememberLazyListState() val scope = rememberCoroutineScope() val categoryFocus = remember { mutableStateMapOf() } - val epgNowTitles = remember { mutableStateMapOf() } + val epgNowTitles = remember { mutableStateMapOf() } LaunchedEffect(Unit) { loading = true @@ -112,7 +112,7 @@ fun TvScreen( } } - fun loadEpg(streamId: Int) { + fun loadEpg(streamId: String) { selectedStreamId = streamId onLastStreamChange(streamId) epgJob?.cancel() diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt index b9565e3..3607bdb 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt @@ -487,6 +487,7 @@ class ApiClient( parseObject(execute(req)) { o -> ClientMe( iptvEnabled = o.optJSONObject("iptv")?.optBoolean("enabled") == true, + eventsEnabled = o.optJSONObject("events")?.optBoolean("enabled") == true, profile = o.optJSONObject("profile")?.toProfile(), ) } @@ -596,6 +597,121 @@ class ApiClient( } } + suspend fun listScheduleEvents(): ScheduleEventsPayload = withContext(Dispatchers.IO) { + val req = authed("$baseUrl/api/v1/client/events").get().build() + parseObject(execute(req)) { o -> + val arr = o.optJSONArray("events") ?: JSONArray() + val events = buildList { + for (i in 0 until arr.length()) { + arr.optJSONObject(i)?.toScheduleEvent()?.let { add(it) } + } + } + val providersArr = o.optJSONArray("providers") + val providers = buildList { + if (providersArr != null) { + for (i in 0 until providersArr.length()) { + val p = providersArr.optString(i).trim() + if (p.isNotEmpty()) add(p) + } + } + } + ScheduleEventsPayload( + generatedAt = o.optClean("generatedAt"), + events = events, + providers = providers, + ) + } + } + + suspend fun getScheduleEvent(eventId: String): ScheduleEvent = withContext(Dispatchers.IO) { + val enc = android.net.Uri.encode(eventId) + val req = authed("$baseUrl/api/v1/client/events/$enc").get().build() + parseObject(execute(req)) { o -> + o.getJSONObject("event").toScheduleEvent() + ?: throw ApiException("Event niet gevonden") + } + } + + suspend fun scheduleEventPlay(eventId: String): IptvPlayInfo = withContext(Dispatchers.IO) { + val enc = android.net.Uri.encode(eventId) + val req = authed("$baseUrl/api/v1/client/events/$enc/play").get().build() + parseObject(execute(req)) { o -> + val drmObj = o.optJSONObject("drm") + val drm = if (drmObj != null) { + val keysArr = drmObj.optJSONArray("keys") ?: JSONArray() + val keys = buildList { + for (i in 0 until keysArr.length()) { + val k = keysArr.optJSONObject(i) ?: continue + val kid = k.optString("kid").ifBlank { null } ?: continue + val key = k.optString("key").ifBlank { null } ?: continue + add(ClearKeyEntry(kid = kid, key = key)) + } + } + DrmInfo( + type = drmObj.optString("type").ifBlank { "clearkey" }, + keys = keys, + ).takeIf { it.keys.isNotEmpty() } + } else null + IptvPlayInfo( + streamId = o.optString("eventId").ifBlank { eventId }, + name = o.getString("name"), + logoUrl = o.optString("logoUrl").ifBlank { null }, + streamUrl = o.getString("streamUrl"), + format = o.optString("format").ifBlank { "dash" }, + fallbackStreamUrl = o.optString("fallbackStreamUrl").ifBlank { null }, + fallbackFormat = o.optString("fallbackFormat").ifBlank { null }, + drm = drm, + ) + } + } + + private fun JSONObject.toScheduleEvent(): ScheduleEvent? { + val id = optClean("id") ?: return null + val name = optClean("name") ?: return null + val imagesArr = optJSONArray("images") + val images = buildList { + if (imagesArr != null) { + for (i in 0 until imagesArr.length()) { + val u = imagesArr.optString(i).trim() + if (u.isNotEmpty()) add(u) + } + } + } + val audioArr = optJSONArray("audioLanguages") + val audio = buildList { + if (audioArr != null) { + for (i in 0 until audioArr.length()) { + val a = audioArr.optString(i).trim() + if (a.isNotEmpty()) add(a) + } + } + } + return ScheduleEvent( + id = id, + name = name, + description = optClean("description"), + provider = optClean("provider") ?: "unknown", + category = optClean("category"), + eventType = optClean("eventType"), + subtitle = optClean("subtitle"), + channel = optClean("channel"), + network = optClean("network"), + isLive = optBoolean("isLive"), + phase = optClean("phase") ?: "upcoming", + playable = optBoolean("playable"), + status = optClean("status"), + start = optClean("start") ?: "", + stop = optClean("stop") ?: "", + startLocal = optClean("startLocal"), + stopLocal = optClean("stopLocal"), + imageLandscape = optClean("imageLandscape"), + imagePortrait = optClean("imagePortrait"), + logoUrl = optClean("logoUrl"), + images = images, + audioLanguages = audio, + ) + } + suspend fun iptvGuide( categoryId: String? = null, limit: Int = 80, @@ -923,6 +1039,7 @@ data class ShelfPref( data class ClientMe( val iptvEnabled: Boolean, + val eventsEnabled: Boolean = false, val profile: ViewerProfile? = null, ) @@ -950,6 +1067,37 @@ data class IptvCategory( val channelCount: Int, ) +data class ScheduleEventsPayload( + val generatedAt: String? = null, + val events: List = emptyList(), + val providers: List = emptyList(), +) + +data class ScheduleEvent( + val id: String, + val name: String, + val description: String? = null, + val provider: String, + val category: String? = null, + val eventType: String? = null, + val subtitle: String? = null, + val channel: String? = null, + val network: String? = null, + val isLive: Boolean = false, + val phase: String = "upcoming", + val playable: Boolean = false, + val status: String? = null, + val start: String = "", + val stop: String = "", + val startLocal: String? = null, + val stopLocal: String? = null, + val imageLandscape: String? = null, + val imagePortrait: String? = null, + val logoUrl: String? = null, + val images: List = emptyList(), + val audioLanguages: List = emptyList(), +) + data class IptvChannel( val streamId: String, val name: String, diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/EventPlayerScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/EventPlayerScreen.kt new file mode 100644 index 0000000..8e98839 --- /dev/null +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/EventPlayerScreen.kt @@ -0,0 +1,141 @@ +package nl.vonas.mediacluster.tv.ui + +import android.view.ViewGroup +import androidx.activity.compose.BackHandler +import androidx.annotation.OptIn +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.PlayerView +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Text +import kotlinx.coroutines.delay +import nl.vonas.mediacluster.tv.data.ApiClient +import nl.vonas.mediacluster.tv.playback.buildLiveTvPlayer +import nl.vonas.mediacluster.tv.playback.ensureAudioSelected +import nl.vonas.mediacluster.tv.playback.liveMediaItem +import nl.vonas.mediacluster.tv.ui.theme.McColors + +@OptIn(UnstableApi::class) +@kotlin.OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun EventPlayerScreen( + api: ApiClient, + eventId: String, + onBack: () -> Unit, +) { + val context = LocalContext.current + val player = remember { buildLiveTvPlayer(context) } + var title by remember { mutableStateOf("Live event") } + var loading by remember { mutableStateOf(true) } + var error by remember { mutableStateOf(null) } + var showChrome by remember { mutableStateOf(true) } + + LaunchedEffect(eventId) { + loading = true + error = null + try { + val play = api.scheduleEventPlay(eventId) + title = play.name + player.setMediaItem(liveMediaItem(play.streamUrl, play.format, play.drm)) + player.prepare() + player.playWhenReady = true + } catch (e: Exception) { + error = e.message ?: "Kon event niet afspelen" + } finally { + loading = false + } + } + + LaunchedEffect(showChrome, error, loading) { + if (showChrome && error == null && !loading) { + delay(4_000) + showChrome = false + } + } + + DisposableEffect(Unit) { + val listener = object : Player.Listener { + override fun onPlaybackStateChanged(state: Int) { + if (state == Player.STATE_READY) player.ensureAudioSelected() + } + + override fun onPlayerError(e: PlaybackException) { + error = e.message ?: "Afspeelfout" + } + } + player.addListener(listener) + onDispose { + player.removeListener(listener) + player.release() + } + } + + BackHandler(onBack = onBack) + + Box( + Modifier + .fillMaxSize() + .background(Color.Black), + ) { + AndroidView( + factory = { ctx -> + PlayerView(ctx).apply { + useController = false + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + this.player = player + } + }, + update = { it.player = player }, + modifier = Modifier.fillMaxSize(), + ) + + if (loading) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("Stream starten…", color = Color.White, fontSize = 18.sp) + } + } + if (error != null) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("Kan event niet afspelen", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 22.sp) + Text(error!!, color = McColors.Danger, fontSize = 15.sp, modifier = Modifier.padding(top = 8.dp)) + Text("Druk op Terug", color = McColors.TextDim, fontSize = 14.sp, modifier = Modifier.padding(top = 12.dp)) + } + } + } else if (showChrome) { + Text( + title, + color = Color.White, + fontSize = 22.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier + .align(Alignment.TopStart) + .padding(28.dp), + ) + } + } +} diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/EventsScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/EventsScreen.kt new file mode 100644 index 0000000..716fab0 --- /dev/null +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/EventsScreen.kt @@ -0,0 +1,812 @@ +package nl.vonas.mediacluster.tv.ui + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.tv.material3.ClickableSurfaceDefaults +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Glow +import androidx.tv.material3.Surface +import androidx.tv.material3.Text +import coil.compose.AsyncImage +import kotlinx.coroutines.delay +import nl.vonas.mediacluster.tv.data.ApiClient +import nl.vonas.mediacluster.tv.data.ScheduleEvent +import nl.vonas.mediacluster.tv.ui.components.HomeChrome +import nl.vonas.mediacluster.tv.ui.theme.McColors +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit +import java.util.Locale + +private val ZoneNl: ZoneId = ZoneId.of("Europe/Amsterdam") +private val TimeFmt: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm") +private val DayTimeFmt: DateTimeFormatter = + DateTimeFormatter.ofPattern("EEE d MMM · HH:mm", Locale("nl", "NL")) + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun EventsScreen( + api: ApiClient, + onOpenEvent: (String) -> Unit, + onPlayEvent: (String) -> Unit, + onContentFocused: () -> Unit = {}, +) { + var events by remember { mutableStateOf>(emptyList()) } + var loading by remember { mutableStateOf(true) } + var error by remember { mutableStateOf(null) } + var providerFilter by remember { mutableStateOf(null) } + var nowMs by remember { mutableLongStateOf(System.currentTimeMillis()) } + val listState = rememberLazyListState() + val heroFocus = remember { FocusRequester() } + + suspend fun refresh() { + runCatching { api.listScheduleEvents() } + .onSuccess { + events = it.events + error = null + } + .onFailure { error = it.message ?: "Kon events niet laden" } + loading = false + } + + LaunchedEffect(Unit) { + refresh() + runCatching { heroFocus.requestFocus() } + } + LaunchedEffect(Unit) { + while (true) { + delay(75_000) + refresh() + } + } + LaunchedEffect(Unit) { + while (true) { + delay(30_000) + nowMs = System.currentTimeMillis() + } + } + + val filtered = remember(events, providerFilter) { + if (providerFilter.isNullOrBlank()) events + else events.filter { it.provider.equals(providerFilter, ignoreCase = true) } + } + val live = remember(filtered) { filtered.filter { it.phase == "live" || it.isLive } } + val hero = live.firstOrNull { it.playable } ?: live.firstOrNull() ?: filtered.firstOrNull() + val byDay = remember(filtered, nowMs) { groupEventsByDay(filtered) } + val providers = remember(events) { + events.map { it.provider }.distinct().sorted() + } + + Box(Modifier.fillMaxSize().background(McColors.Bg)) { + when { + loading && events.isEmpty() -> { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("Events laden…", color = McColors.TextDim, fontSize = 18.sp) + } + } + error != null && events.isEmpty() -> { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text(error!!, color = McColors.Danger, fontSize = 16.sp) + } + } + filtered.isEmpty() -> { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("Geen events in de komende dagen", color = McColors.TextDim, fontSize = 18.sp) + } + } + else -> { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 48.dp), + ) { + item { + if (hero != null) { + EventHero( + event = hero, + nowMs = nowMs, + focusRequester = heroFocus, + onFocus = onContentFocused, + onOpen = { onOpenEvent(hero.id) }, + onPlay = { onPlayEvent(hero.id) }, + ) + } + } + if (providers.size > 1) { + item { + ProviderChips( + providers = providers, + selected = providerFilter, + onSelect = { providerFilter = it }, + ) + } + } + if (live.isNotEmpty()) { + item { + EventRail( + title = "Nu live", + events = live, + nowMs = nowMs, + onOpen = onOpenEvent, + onFocus = onContentFocused, + ) + } + } + byDay.forEach { (label, dayEvents) -> + item { + EventRail( + title = label, + events = dayEvents.filter { it.phase != "live" && !it.isLive }, + nowMs = nowMs, + onOpen = onOpenEvent, + onFocus = onContentFocused, + ) + } + } + } + } + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun EventDetailScreen( + api: ApiClient, + eventId: String, + onBack: () -> Unit, + onPlay: (String) -> Unit, +) { + var event by remember { mutableStateOf(null) } + var error by remember { mutableStateOf(null) } + var nowMs by remember { mutableLongStateOf(System.currentTimeMillis()) } + val playFocus = remember { FocusRequester() } + + LaunchedEffect(eventId) { + runCatching { api.getScheduleEvent(eventId) } + .onSuccess { + event = it + error = null + delay(80) + runCatching { playFocus.requestFocus() } + } + .onFailure { error = it.message ?: "Event niet gevonden" } + } + LaunchedEffect(Unit) { + while (true) { + delay(30_000) + nowMs = System.currentTimeMillis() + runCatching { api.getScheduleEvent(eventId) }.onSuccess { event = it } + } + } + + BackHandler(onBack = onBack) + + val ev = event + Box(Modifier.fillMaxSize().background(McColors.Bg)) { + when { + ev == null && error == null -> { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("Laden…", color = McColors.TextDim, fontSize = 18.sp) + } + } + ev == null -> { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(error ?: "Fout", color = McColors.Danger) + Spacer(Modifier.height(16.dp)) + McDetailButton("Terug", onClick = onBack, focusRequester = playFocus) + } + } + } + else -> { + LazyColumn(Modifier.fillMaxSize()) { + item { + Box( + Modifier + .fillMaxWidth() + .height(420.dp), + ) { + AsyncImage( + model = ev.imageLandscape ?: ev.images.firstOrNull(), + contentDescription = ev.name, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + Box( + Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + listOf( + Color.Transparent, + Color(0x880B0B0B), + McColors.Bg, + ), + ), + ), + ) + Column( + Modifier + .align(Alignment.BottomStart) + .padding(horizontal = HomeChrome.EdgeInset, vertical = 28.dp) + .fillMaxWidth(0.72f), + ) { + StatusBadge(ev, nowMs) + Spacer(Modifier.height(10.dp)) + Text( + ev.name, + color = McColors.Text, + fontSize = 34.sp, + fontWeight = FontWeight.Bold, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(8.dp)) + Text( + metaLine(ev), + color = McColors.TextDim, + fontSize = 15.sp, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(6.dp)) + Text( + timeWindowLabel(ev), + color = McColors.Text, + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + ) + } + ev.logoUrl?.let { logo -> + AsyncImage( + model = logo, + contentDescription = null, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(HomeChrome.EdgeInset) + .height(48.dp) + .width(120.dp), + contentScale = ContentScale.Fit, + ) + } + } + } + item { + Row( + Modifier.padding(horizontal = HomeChrome.EdgeInset), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (ev.playable) { + McDetailButton( + "Kijk live", + primary = true, + focusRequester = playFocus, + onClick = { onPlay(ev.id) }, + ) + } else { + McDetailButton( + ctaLabel(ev, nowMs), + primary = false, + enabled = false, + focusRequester = playFocus, + onClick = {}, + ) + } + McDetailButton("Terug", onClick = onBack) + } + } + if (!ev.description.isNullOrBlank() || !ev.subtitle.isNullOrBlank()) { + item { + Column(Modifier.padding(HomeChrome.EdgeInset).padding(top = 22.dp)) { + if (!ev.eventType.isNullOrBlank() || !ev.subtitle.isNullOrBlank()) { + Text( + listOfNotNull(ev.eventType, ev.subtitle) + .joinToString(" · "), + color = McColors.TextDim, + fontSize = 15.sp, + ) + Spacer(Modifier.height(10.dp)) + } + if (!ev.description.isNullOrBlank()) { + Text( + ev.description!!, + color = McColors.Text, + fontSize = 16.sp, + lineHeight = 24.sp, + ) + } + } + } + } + if (ev.audioLanguages.isNotEmpty()) { + item { + Column(Modifier.padding(HomeChrome.EdgeInset).padding(top = 18.dp)) { + Text("Audio", color = McColors.TextDim, fontSize = 13.sp) + Spacer(Modifier.height(8.dp)) + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + items(ev.audioLanguages) { lang -> + Box( + Modifier + .clip(RoundedCornerShape(6.dp)) + .background(McColors.Surface) + .padding(horizontal = 10.dp, vertical = 6.dp), + ) { + Text(lang.uppercase(), color = McColors.Text, fontSize = 13.sp) + } + } + } + } + } + } + val gallery = ev.images.distinct().filter { + it != ev.imageLandscape && it != ev.logoUrl + }.take(12) + if (gallery.isNotEmpty()) { + item { + Column(Modifier.padding(top = 28.dp)) { + Text( + "Beelden", + color = McColors.Text, + fontSize = 20.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(horizontal = HomeChrome.EdgeInset), + ) + Spacer(Modifier.height(12.dp)) + LazyRow( + contentPadding = PaddingValues(horizontal = HomeChrome.EdgeInset), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(gallery) { url -> + AsyncImage( + model = url, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .width(220.dp) + .height(124.dp) + .clip(RoundedCornerShape(10.dp)), + ) + } + } + Spacer(Modifier.height(40.dp)) + } + } + } else { + item { Spacer(Modifier.height(40.dp)) } + } + } + } + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun EventHero( + event: ScheduleEvent, + nowMs: Long, + focusRequester: FocusRequester, + onFocus: () -> Unit, + onOpen: () -> Unit, + onPlay: () -> Unit, +) { + Box( + Modifier + .fillMaxWidth() + .height(460.dp), + ) { + AsyncImage( + model = event.imageLandscape ?: event.images.firstOrNull(), + contentDescription = event.name, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + Box(Modifier.fillMaxSize().background(McColors.HeroScrim)) + Column( + Modifier + .align(Alignment.BottomStart) + .padding(start = HomeChrome.EdgeInset, end = 80.dp, bottom = 36.dp) + .fillMaxWidth(0.65f), + ) { + StatusBadge(event, nowMs) + Spacer(Modifier.height(12.dp)) + Text( + event.name, + color = McColors.Text, + fontSize = 36.sp, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(8.dp)) + Text( + metaLine(event), + color = McColors.TextDim, + fontSize = 15.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(4.dp)) + Text(timeWindowLabel(event), color = McColors.Text, fontSize = 15.sp) + Spacer(Modifier.height(18.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + if (event.playable) { + McDetailButton( + "Kijk live", + primary = true, + focusRequester = focusRequester, + onFocus = onFocus, + onClick = onPlay, + ) + } + McDetailButton( + if (event.playable) "Meer info" else ctaLabel(event, nowMs), + focusRequester = if (!event.playable) focusRequester else null, + onFocus = onFocus, + onClick = onOpen, + ) + } + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun EventRail( + title: String, + events: List, + nowMs: Long, + onOpen: (String) -> Unit, + onFocus: () -> Unit, +) { + if (events.isEmpty()) return + Column(Modifier.padding(top = 22.dp)) { + Text( + title, + color = McColors.Text, + fontSize = 22.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(horizontal = HomeChrome.EdgeInset), + ) + Spacer(Modifier.height(12.dp)) + LazyRow( + contentPadding = PaddingValues(horizontal = HomeChrome.EdgeInset), + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + items(events, key = { it.id }) { ev -> + EventCard(ev, nowMs, onOpen = { onOpen(ev.id) }, onFocus = onFocus) + } + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun EventCard( + event: ScheduleEvent, + nowMs: Long, + onOpen: () -> Unit, + onFocus: () -> Unit, +) { + var focused by remember { mutableStateOf(false) } + Surface( + onClick = onOpen, + modifier = Modifier + .width(280.dp) + .onFocusChanged { + focused = it.isFocused + if (it.isFocused) onFocus() + }, + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f), + glow = ClickableSurfaceDefaults.glow( + focusedGlow = Glow(elevationColor = McColors.Accent, elevation = 8.dp), + ), + colors = ClickableSurfaceDefaults.colors( + containerColor = McColors.Surface, + focusedContainerColor = McColors.Surface, + ), + shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(12.dp)), + ) { + Column { + Box( + Modifier + .fillMaxWidth() + .height(158.dp), + ) { + AsyncImage( + model = event.imageLandscape ?: event.imagePortrait ?: event.images.firstOrNull(), + contentDescription = event.name, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + Box( + Modifier + .align(Alignment.TopStart) + .padding(8.dp), + ) { + StatusBadge(event, nowMs, compact = true) + } + } + Column(Modifier.padding(12.dp)) { + Text( + event.name, + color = if (focused) Color.White else McColors.Text, + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.height(42.dp), + ) + Spacer(Modifier.height(4.dp)) + Text( + listOfNotNull( + prettyProvider(event.provider), + shortTimeLabel(event, nowMs), + ).joinToString(" · "), + color = McColors.TextDim, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun ProviderChips( + providers: List, + selected: String?, + onSelect: (String?) -> Unit, +) { + LazyRow( + contentPadding = PaddingValues(horizontal = HomeChrome.EdgeInset, vertical = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + item { + Chip("Alles", selected == null) { onSelect(null) } + } + items(providers) { p -> + Chip(prettyProvider(p), selected.equals(p, ignoreCase = true)) { onSelect(p) } + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun Chip(label: String, active: Boolean, onClick: () -> Unit) { + Surface( + onClick = onClick, + colors = ClickableSurfaceDefaults.colors( + containerColor = if (active) McColors.Accent else McColors.Surface, + focusedContainerColor = if (active) McColors.Accent else McColors.SurfaceFocus, + focusedContentColor = if (active) Color.White else Color.Black, + contentColor = Color.White, + ), + shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(20.dp)), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f), + ) { + Text( + label, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp), + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + ) + } +} + +@Composable +private fun StatusBadge(event: ScheduleEvent, nowMs: Long, compact: Boolean = false) { + val (label, bg) = statusVisual(event, nowMs) + Box( + Modifier + .clip(RoundedCornerShape(4.dp)) + .background(bg) + .padding( + horizontal = if (compact) 8.dp else 10.dp, + vertical = if (compact) 3.dp else 5.dp, + ), + ) { + Text( + label, + color = Color.White, + fontSize = if (compact) 11.sp else 13.sp, + fontWeight = FontWeight.Bold, + ) + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun McDetailButton( + label: String, + onClick: () -> Unit, + primary: Boolean = false, + enabled: Boolean = true, + focusRequester: FocusRequester? = null, + onFocus: (() -> Unit)? = null, +) { + var focused by remember { mutableStateOf(false) } + val mod = Modifier + .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) + .onFocusChanged { + focused = it.isFocused + if (it.isFocused) onFocus?.invoke() + } + .height(48.dp) + Surface( + onClick = { if (enabled) onClick() }, + enabled = enabled, + modifier = mod, + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f), + colors = ClickableSurfaceDefaults.colors( + containerColor = when { + !enabled -> McColors.Surface.copy(alpha = 0.55f) + primary -> McColors.Accent + else -> McColors.Surface + }, + focusedContainerColor = when { + !enabled -> McColors.Surface + primary -> Color.White + else -> McColors.SurfaceFocus + }, + contentColor = Color.White, + focusedContentColor = if (primary && enabled) Color.Black else Color.Black, + disabledContainerColor = McColors.Surface.copy(alpha = 0.45f), + disabledContentColor = McColors.TextDim, + ), + shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(8.dp)), + ) { + Box( + Modifier.padding(horizontal = 22.dp), + contentAlignment = Alignment.Center, + ) { + Text(label, fontWeight = FontWeight.SemiBold, fontSize = 15.sp) + } + } +} + +private fun statusVisual(event: ScheduleEvent, nowMs: Long): Pair { + return when { + event.phase == "live" || event.isLive -> "LIVE" to McColors.Accent + minutesUntilStart(event, nowMs)?.let { it in 0 until 60 } == true -> { + val m = minutesUntilStart(event, nowMs)!! + "Straks · over ${m} min" to Color(0xFFE67E22) + } + else -> "Gepland" to Color(0xFF3A3A3A) + } +} + +private fun ctaLabel(event: ScheduleEvent, nowMs: Long): String { + if (event.phase == "live" || event.isLive) return "Stream start binnenkort" + val mins = minutesUntilStart(event, nowMs) + if (mins != null && mins in 0 until 60) return "Start over $mins min" + val start = parseZoned(event.startLocal, event.start) ?: return "Meer info" + return "Start om ${start.format(TimeFmt)}" +} + +private fun shortTimeLabel(event: ScheduleEvent, nowMs: Long): String { + if (event.phase == "live" || event.isLive) { + val left = minutesUntilEnd(event, nowMs) + return if (left != null && left > 0) "Nog $left min" else "Nu" + } + val mins = minutesUntilStart(event, nowMs) + if (mins != null && mins in 0 until 60) return "Over $mins min" + val start = parseZoned(event.startLocal, event.start) ?: return "" + val today = LocalDate.now(ZoneNl) + val day = start.toLocalDate() + return when { + day == today -> "Vandaag ${start.format(TimeFmt)}" + day == today.plusDays(1) -> "Morgen ${start.format(TimeFmt)}" + else -> start.format(DayTimeFmt) + } +} + +private fun timeWindowLabel(event: ScheduleEvent): String { + val start = parseZoned(event.startLocal, event.start) + val stop = parseZoned(event.stopLocal, event.stop) + if (start == null || stop == null) return "" + return "${start.format(TimeFmt)} – ${stop.format(TimeFmt)}" +} + +private fun metaLine(event: ScheduleEvent): String = + listOfNotNull( + prettyProvider(event.provider), + event.eventType, + event.channel ?: event.network, + ).joinToString(" · ") + +private fun prettyProvider(raw: String): String = when (raw.lowercase()) { + "viaplay" -> "Viaplay" + "ziggosport" -> "Ziggo Sport" + "espn" -> "ESPN" + "appletv" -> "Apple TV" + "prime_hbo" -> "Prime / HBO" + "prime" -> "Prime Video" + "disneyplus" -> "Disney+" + "eyecons" -> "Eyecons" + "odido" -> "Odido" + else -> raw.replace('_', ' ').replaceFirstChar { it.uppercase() } +} + +private fun groupEventsByDay(events: List): List>> { + val today = LocalDate.now(ZoneNl) + val groups = linkedMapOf>() + for (e in events) { + val start = parseZoned(e.startLocal, e.start) ?: continue + val day = start.toLocalDate() + val label = when { + day == today -> "Vandaag" + day == today.plusDays(1) -> "Morgen" + day == today.plusDays(2) -> "Overmorgen" + else -> day.format(DateTimeFormatter.ofPattern("EEEE d MMMM", Locale("nl", "NL"))) + } + groups.getOrPut(label) { mutableListOf() }.add(e) + } + return groups.map { it.key to it.value } +} + +private fun parseZoned(local: String?, utc: String): ZonedDateTime? { + val raw = local?.takeIf { it.isNotBlank() } ?: utc.takeIf { it.isNotBlank() } ?: return null + return runCatching { + if (raw.contains('T')) { + val odt = java.time.OffsetDateTime.parse(raw) + odt.atZoneSameInstant(ZoneNl) + } else null + }.getOrNull() ?: runCatching { + Instant.parse(if (raw.endsWith("Z") || raw.contains('+')) raw else "${raw}Z") + .atZone(ZoneNl) + }.getOrNull() +} + +private fun minutesUntilStart(event: ScheduleEvent, nowMs: Long): Long? { + val start = parseZoned(event.startLocal, event.start) ?: return null + return ChronoUnit.MINUTES.between(Instant.ofEpochMilli(nowMs).atZone(ZoneNl), start) +} + +private fun minutesUntilEnd(event: ScheduleEvent, nowMs: Long): Long? { + val stop = parseZoned(event.stopLocal, event.stop) ?: return null + return ChronoUnit.MINUTES.between(Instant.ofEpochMilli(nowMs).atZone(ZoneNl), stop) +} diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt index 7ba18ac..6df512a 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt @@ -341,6 +341,12 @@ fun McNav() { if (hubTabName != MainTab.Tv.name) hubTabName = MainTab.Tv.name nav.navigate("live/${android.net.Uri.encode(streamId)}") }, + onOpenEvent = { eventId -> + nav.navigate("event/${android.net.Uri.encode(eventId)}") + }, + onPlayEvent = { eventId -> + nav.navigate("event_play/${android.net.Uri.encode(eventId)}") + }, onSwitchProfile = { scope.launch { runCatching { api.clearProfile() } @@ -463,6 +469,35 @@ fun McNav() { onBack = { nav.popBackStack() }, ) } + composable( + "event/{eventId}", + arguments = listOf(navArgument("eventId") { type = NavType.StringType }), + ) { entry -> + val eventId = entry.arguments?.getString("eventId") + ?.let { android.net.Uri.decode(it) } + ?: return@composable + EventDetailScreen( + api = api, + eventId = eventId, + onBack = { nav.popBackStack() }, + onPlay = { id -> + nav.navigate("event_play/${android.net.Uri.encode(id)}") + }, + ) + } + composable( + "event_play/{eventId}", + arguments = listOf(navArgument("eventId") { type = NavType.StringType }), + ) { entry -> + val eventId = entry.arguments?.getString("eventId") + ?.let { android.net.Uri.decode(it) } + ?: return@composable + EventPlayerScreen( + api = api, + eventId = eventId, + onBack = { nav.popBackStack() }, + ) + } } } @@ -493,12 +528,15 @@ private fun MainHub( onBrowse: (catalogId: String, title: String) -> Unit, onPlay: (mediaFileId: String, movieId: String?, episodeId: String?, seriesId: String?, type: String, startMs: Long) -> Unit, onPlayLive: (String) -> Unit, + onOpenEvent: (String) -> Unit, + onPlayEvent: (String) -> Unit, onSwitchProfile: () -> Unit, onManageProfiles: () -> Unit, onLogout: () -> Unit, isOwner: Boolean = false, ) { var iptvEnabled by remember { mutableStateOf(false) } + var eventsEnabled by remember { mutableStateOf(false) } var navFocused by remember { mutableStateOf(false) } var exitConfirm by remember { mutableStateOf(false) } var homeChromeCompact by remember { mutableStateOf(false) } @@ -530,13 +568,18 @@ private fun MainHub( LaunchedEffect(Unit) { runCatching { shelfPrefs = api.getShelfPrefs().sortedBy { it.sortOrder } } - runCatching { iptvEnabled = api.clientMe().iptvEnabled } + runCatching { + val me = api.clientMe() + iptvEnabled = me.iptvEnabled + eventsEnabled = me.eventsEnabled + } } - val visibleTabs = remember(iptvEnabled) { + val visibleTabs = remember(iptvEnabled, eventsEnabled) { MainTab.entries.filter { it != MainTab.Settings && it != MainTab.Search && - ((it != MainTab.Tv && it != MainTab.TvGuide) || iptvEnabled) + ((it != MainTab.Tv && it != MainTab.TvGuide) || iptvEnabled) && + (it != MainTab.Events || eventsEnabled) } } @@ -629,12 +672,12 @@ private fun MainHub( focusTopNav() } // TV / TV Gids: content scrollt terug, daarna top-nav - BackHandler(enabled = (tab == MainTab.Tv || tab == MainTab.TvGuide) && !navFocused && !exitConfirm) { + BackHandler(enabled = (tab == MainTab.Tv || tab == MainTab.TvGuide || tab == MainTab.Events) && !navFocused && !exitConfirm) { focusTopNav() } LaunchedEffect(tvFocusNonce) { - if (tab == MainTab.Tv || tab == MainTab.TvGuide) { + if (tab == MainTab.Tv || tab == MainTab.TvGuide || tab == MainTab.Events) { navFocused = false } } @@ -650,7 +693,7 @@ private fun MainHub( prevTabName = name } - val homeOverlay = tab == MainTab.Home + val homeOverlay = tab == MainTab.Home || tab == MainTab.Events val density = LocalDensity.current var topNavHeightPx by remember { mutableIntStateOf(0) } val topNavBlockHeight = with(density) { @@ -685,11 +728,12 @@ private fun MainHub( }, onSelect = { if ((it == MainTab.Tv || it == MainTab.TvGuide) && !iptvEnabled) return@TopNavBar + if (it == MainTab.Events && !eventsEnabled) return@TopNavBar onTabChange(it) exitConfirm = false homeChromeCompact = false when (it) { - MainTab.Tv, MainTab.TvGuide -> { + MainTab.Tv, MainTab.TvGuide, MainTab.Events -> { navFocused = false onTvFocusContent() } @@ -824,6 +868,12 @@ private fun MainHub( }, onContentFocused = { navFocused = false }, ) + MainTab.Events -> EventsScreen( + api = api, + onOpenEvent = onOpenEvent, + onPlayEvent = onPlayEvent, + onContentFocused = { navFocused = false }, + ) MainTab.Search -> SearchScreen( api = api, onOpenMovie = onOpenMovie, diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/TopNav.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/TopNav.kt index 719e563..49adfe7 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/TopNav.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/TopNav.kt @@ -73,6 +73,7 @@ enum class MainTab(val label: String) { Series("Series"), Tv("TV"), TvGuide("TV Gids"), + Events("Events"), Search("Zoeken"), Settings("Instellingen"), } diff --git a/apps/ios/README.md b/apps/ios/README.md index f6ec68b..8678ce8 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -18,17 +18,26 @@ 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**) +2. iPhone selecteren → ▶ Run -## 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 +## Apple TV (tvOS) + +Apart project (zelfde API, TV-UI met focus/remote): + +```bash +cd ~/Code/mediacluster/apps/ios +bash scripts/fetch-tvvlckit.sh +bash scripts/link-tvvlckit.sh +open VMCTV.xcodeproj +``` + +- Bundle ID: `nl.vonas.mediacluster.tvos` +- Team: `X25NYX3K49` +- Playback: **TVVLCKit** (zelfde VLC-engine als iPhone) — MKV werkt +- Details: `VMCTV/README.md` ## Let op -`Vendor/MobileVLCKit.xcframework` staat niet in git (te groot). Altijd op de Mac via de scripts installeren. +`Vendor/MobileVLCKit.xcframework` en `Vendor/TVVLCKit.xcframework` staan niet in git (te groot). Altijd op de Mac via de scripts installeren. diff --git a/apps/ios/VMCTV.xcodeproj/project.pbxproj b/apps/ios/VMCTV.xcodeproj/project.pbxproj new file mode 100644 index 0000000..01f1123 --- /dev/null +++ b/apps/ios/VMCTV.xcodeproj/project.pbxproj @@ -0,0 +1,418 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + A10000000000000000000001 /* VMCTVApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000001 /* VMCTVApp.swift */; }; + A10000000000000000000002 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000002 /* Models.swift */; }; + A10000000000000000000003 /* ApiClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000003 /* ApiClient.swift */; }; + A10000000000000000000004 /* SessionStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000004 /* SessionStore.swift */; }; + A10000000000000000000005 /* FavoritesStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000005 /* FavoritesStore.swift */; }; + A10000000000000000000006 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000006 /* AppModel.swift */; }; + A10000000000000000000007 /* TvTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000007 /* TvTheme.swift */; }; + A10000000000000000000008 /* ProfileAvatars.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000008 /* ProfileAvatars.swift */; }; + A10000000000000000000009 /* Components.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000009 /* Components.swift */; }; + A1000000000000000000000A /* TopNav.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000000000000000000000A /* TopNav.swift */; }; + A1000000000000000000000B /* MainHub.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000000000000000000000B /* MainHub.swift */; }; + A1000000000000000000000C /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000000000000000000000C /* HomeView.swift */; }; + A1000000000000000000000D /* BrowseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000000000000000000000D /* BrowseView.swift */; }; + A1000000000000000000000E /* WatchlistView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000000000000000000000E /* WatchlistView.swift */; }; + A1000000000000000000000F /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000000000000000000000F /* SearchView.swift */; }; + A10000000000000000000010 /* LiveTvView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000010 /* LiveTvView.swift */; }; + A10000000000000000000011 /* DetailViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000011 /* DetailViews.swift */; }; + A10000000000000000000012 /* AuthViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000012 /* AuthViews.swift */; }; + A10000000000000000000013 /* ProfilesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000013 /* ProfilesView.swift */; }; + A10000000000000000000014 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000014 /* SettingsView.swift */; }; + A1000000000000000000001D /* ManageProfilesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000000000000000000001D /* ManageProfilesView.swift */; }; + A10000000000000000000015 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000015 /* RootView.swift */; }; + A10000000000000000000016 /* TVPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000016 /* TVPlayerView.swift */; }; + A10000000000000000000017 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000017 /* Assets.xcassets */; }; + A1000000000000000000001A /* VLCPlayerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000000000000000000001A /* VLCPlayerController.swift */; }; + A1000000000000000000001B /* TVVLCKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = B1000000000000000000001B /* TVVLCKit.xcframework */; }; + A1000000000000000000001C /* TVVLCKit.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = B1000000000000000000001B /* TVVLCKit.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + B10000000000000000000001 /* VMCTVApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMCTVApp.swift; sourceTree = ""; }; + B10000000000000000000002 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = ""; }; + B10000000000000000000003 /* ApiClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiClient.swift; sourceTree = ""; }; + B10000000000000000000004 /* SessionStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionStore.swift; sourceTree = ""; }; + B10000000000000000000005 /* FavoritesStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesStore.swift; sourceTree = ""; }; + B10000000000000000000006 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = ""; }; + B10000000000000000000007 /* TvTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TvTheme.swift; sourceTree = ""; }; + B10000000000000000000008 /* ProfileAvatars.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileAvatars.swift; sourceTree = ""; }; + B10000000000000000000009 /* Components.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Components.swift; sourceTree = ""; }; + B1000000000000000000000A /* TopNav.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopNav.swift; sourceTree = ""; }; + B1000000000000000000000B /* MainHub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHub.swift; sourceTree = ""; }; + B1000000000000000000000C /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; + B1000000000000000000000D /* BrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseView.swift; sourceTree = ""; }; + B1000000000000000000000E /* WatchlistView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchlistView.swift; sourceTree = ""; }; + B1000000000000000000000F /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = ""; }; + B10000000000000000000010 /* LiveTvView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTvView.swift; sourceTree = ""; }; + B10000000000000000000011 /* DetailViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailViews.swift; sourceTree = ""; }; + B10000000000000000000012 /* AuthViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthViews.swift; sourceTree = ""; }; + B10000000000000000000013 /* ProfilesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilesView.swift; sourceTree = ""; }; + B10000000000000000000014 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + B1000000000000000000001D /* ManageProfilesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManageProfilesView.swift; sourceTree = ""; }; + B10000000000000000000015 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; + B10000000000000000000016 /* TVPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVPlayerView.swift; sourceTree = ""; }; + B10000000000000000000017 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + B10000000000000000000018 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + B10000000000000000000019 /* VMCTV.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VMCTV.app; sourceTree = BUILT_PRODUCTS_DIR; }; + B1000000000000000000001A /* VLCPlayerController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VLCPlayerController.swift; sourceTree = ""; }; + B1000000000000000000001B /* TVVLCKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = TVVLCKit.xcframework; path = Vendor/TVVLCKit.xcframework; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXCopyFilesBuildPhase section */ + E10000000000000000000005 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + A1000000000000000000001C /* TVVLCKit.xcframework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFrameworksBuildPhase section */ + C10000000000000000000001 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + A1000000000000000000001B /* TVVLCKit.xcframework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + D10000000000000000000001 = { + isa = PBXGroup; + children = ( + D10000000000000000000002 /* VMCTV */, + B1000000000000000000001B /* TVVLCKit.xcframework */, + D10000000000000000000003 /* Products */, + ); + sourceTree = ""; + }; + D10000000000000000000003 /* Products */ = { + isa = PBXGroup; + children = ( + B10000000000000000000019 /* VMCTV.app */, + ); + name = Products; + sourceTree = ""; + }; + D10000000000000000000002 /* VMCTV */ = { + isa = PBXGroup; + children = ( + B10000000000000000000001 /* VMCTVApp.swift */, + D10000000000000000000004 /* Data */, + D10000000000000000000005 /* Theme */, + D10000000000000000000006 /* Playback */, + D10000000000000000000007 /* UI */, + B10000000000000000000017 /* Assets.xcassets */, + B10000000000000000000018 /* Info.plist */, + ); + path = VMCTV; + sourceTree = ""; + }; + D10000000000000000000004 /* Data */ = { + isa = PBXGroup; + children = ( + B10000000000000000000002 /* Models.swift */, + B10000000000000000000003 /* ApiClient.swift */, + B10000000000000000000004 /* SessionStore.swift */, + B10000000000000000000005 /* FavoritesStore.swift */, + B10000000000000000000006 /* AppModel.swift */, + ); + path = Data; + sourceTree = ""; + }; + D10000000000000000000005 /* Theme */ = { + isa = PBXGroup; + children = ( + B10000000000000000000007 /* TvTheme.swift */, + B10000000000000000000008 /* ProfileAvatars.swift */, + ); + path = Theme; + sourceTree = ""; + }; + D10000000000000000000006 /* Playback */ = { + isa = PBXGroup; + children = ( + B10000000000000000000016 /* TVPlayerView.swift */, + B1000000000000000000001A /* VLCPlayerController.swift */, + ); + path = Playback; + sourceTree = ""; + }; + D10000000000000000000007 /* UI */ = { + isa = PBXGroup; + children = ( + B10000000000000000000009 /* Components.swift */, + B1000000000000000000000A /* TopNav.swift */, + B1000000000000000000000B /* MainHub.swift */, + B1000000000000000000000C /* HomeView.swift */, + B1000000000000000000000D /* BrowseView.swift */, + B1000000000000000000000E /* WatchlistView.swift */, + B1000000000000000000000F /* SearchView.swift */, + B10000000000000000000010 /* LiveTvView.swift */, + B10000000000000000000011 /* DetailViews.swift */, + B10000000000000000000012 /* AuthViews.swift */, + B10000000000000000000013 /* ProfilesView.swift */, + B10000000000000000000014 /* SettingsView.swift */, + B1000000000000000000001D /* ManageProfilesView.swift */, + B10000000000000000000015 /* RootView.swift */, + ); + path = UI; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + E10000000000000000000001 /* VMCTV */ = { + isa = PBXNativeTarget; + buildConfigurationList = F10000000000000000000003 /* Build configuration list for PBXNativeTarget "VMCTV" */; + buildPhases = ( + E10000000000000000000002 /* Sources */, + C10000000000000000000001 /* Frameworks */, + E10000000000000000000005 /* Embed Frameworks */, + E10000000000000000000003 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = VMCTV; + productName = VMCTV; + productReference = B10000000000000000000019 /* VMCTV.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + E10000000000000000000004 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1500; + LastUpgradeCheck = 1500; + TargetAttributes = { + E10000000000000000000001 = { + CreatedOnToolsVersion = 15.0; + }; + }; + }; + buildConfigurationList = F10000000000000000000001 /* Build configuration list for PBXProject "VMCTV" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = nl; + hasScannedForEncodings = 0; + knownRegions = ( + en, + nl, + Base, + ); + mainGroup = D10000000000000000000001; + productRefGroup = D10000000000000000000003 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + E10000000000000000000001 /* VMCTV */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + E10000000000000000000003 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A10000000000000000000017 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + E10000000000000000000002 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A10000000000000000000001 /* VMCTVApp.swift in Sources */, + A10000000000000000000002 /* Models.swift in Sources */, + A10000000000000000000003 /* ApiClient.swift in Sources */, + A10000000000000000000004 /* SessionStore.swift in Sources */, + A10000000000000000000005 /* FavoritesStore.swift in Sources */, + A10000000000000000000006 /* AppModel.swift in Sources */, + A10000000000000000000007 /* TvTheme.swift in Sources */, + A10000000000000000000008 /* ProfileAvatars.swift in Sources */, + A10000000000000000000009 /* Components.swift in Sources */, + A1000000000000000000000A /* TopNav.swift in Sources */, + A1000000000000000000000B /* MainHub.swift in Sources */, + A1000000000000000000000C /* HomeView.swift in Sources */, + A1000000000000000000000D /* BrowseView.swift in Sources */, + A1000000000000000000000E /* WatchlistView.swift in Sources */, + A1000000000000000000000F /* SearchView.swift in Sources */, + A10000000000000000000010 /* LiveTvView.swift in Sources */, + A10000000000000000000011 /* DetailViews.swift in Sources */, + A10000000000000000000012 /* AuthViews.swift in Sources */, + A10000000000000000000013 /* ProfilesView.swift in Sources */, + A10000000000000000000014 /* SettingsView.swift in Sources */, + A1000000000000000000001D /* ManageProfilesView.swift in Sources */, + A10000000000000000000015 /* RootView.swift in Sources */, + A10000000000000000000016 /* TVPlayerView.swift in Sources */, + A1000000000000000000001A /* VLCPlayerController.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + F10000000000000000000004 /* 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; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = appletvos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + TVOS_DEPLOYMENT_TARGET = 17.0; + }; + name = Debug; + }; + F10000000000000000000005 /* 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"; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = appletvos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TVOS_DEPLOYMENT_TARGET = 17.0; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + F10000000000000000000006 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image"; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 4; + DEVELOPMENT_TEAM = X25NYX3K49; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = VMCTV/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = VMC; + INFOPLIST_KEY_LSRequiresIPhoneOS = NO; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Vendor", + ); + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + ); + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0.0; + PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.tvos; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = appletvos; + SUPPORTED_PLATFORMS = "appletvos appletvsimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 3; + TVOS_DEPLOYMENT_TARGET = 17.0; + }; + name = Debug; + }; + F10000000000000000000007 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image"; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 4; + DEVELOPMENT_TEAM = X25NYX3K49; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = VMCTV/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = VMC; + INFOPLIST_KEY_LSRequiresIPhoneOS = NO; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Vendor", + ); + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + ); + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0.0; + PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.tvos; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = appletvos; + SUPPORTED_PLATFORMS = "appletvos appletvsimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 3; + TVOS_DEPLOYMENT_TARGET = 17.0; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + F10000000000000000000001 /* Build configuration list for PBXProject "VMCTV" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F10000000000000000000004 /* Debug */, + F10000000000000000000005 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + F10000000000000000000003 /* Build configuration list for PBXNativeTarget "VMCTV" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F10000000000000000000006 /* Debug */, + F10000000000000000000007 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = E10000000000000000000004 /* Project object */; +} diff --git a/apps/ios/VMCTV/Assets.xcassets/AccentColor.colorset/Contents.json b/apps/ios/VMCTV/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..64ce80f --- /dev/null +++ b/apps/ios/VMCTV/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.078", + "green" : "0.035", + "red" : "0.898" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Back.png b/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Back.png new file mode 100644 index 0000000000000000000000000000000000000000..ca93ec2d6253d9722a1d4cd48b5c0aec3a0f7bc3 GIT binary patch literal 45740 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 F{tpA0CSw2q literal 0 HcmV?d00001 diff --git a/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Contents.json b/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Contents.json new file mode 100644 index 0000000..225ac19 --- /dev/null +++ b/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Contents.json @@ -0,0 +1,6 @@ +{ + "images" : [ + { "filename" : "Back.png", "idiom" : "tv", "scale" : "1x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} \ No newline at end of file diff --git a/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Contents.json b/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Contents.json new file mode 100644 index 0000000..fe13228 --- /dev/null +++ b/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Contents.json @@ -0,0 +1,7 @@ +{ + "info" : { "author" : "xcode", "version" : 1 }, + "properties" : { + "frame-size" : { "height" : 768, "width" : 1280 }, + "frame-center" : { "x" : 640, "y" : 384 } + } +} \ No newline at end of file diff --git a/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Contents.json b/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Contents.json new file mode 100644 index 0000000..b6ff329 --- /dev/null +++ b/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Contents.json @@ -0,0 +1,8 @@ +{ + "info" : { "author" : "xcode", "version" : 1 }, + "layers" : [ + { "filename" : "Front.imagestacklayer" }, + { "filename" : "Middle.imagestacklayer" }, + { "filename" : "Back.imagestacklayer" } + ] +} \ No newline at end of file diff --git a/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Contents.json b/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Contents.json new file mode 100644 index 0000000..15d727e --- /dev/null +++ b/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Contents.json @@ -0,0 +1,6 @@ +{ + "images" : [ + { "filename" : "Front.png", "idiom" : "tv", "scale" : "1x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} \ No newline at end of file diff --git a/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Front.png b/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Front.png new file mode 100644 index 0000000000000000000000000000000000000000..e198a106fa6ba6d499b23a633f6bd812108c5d5b GIT binary patch literal 10013 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 YKHS^AGL8QHD>Mvq)avKbBObr}7kRjq^#A|> literal 0 HcmV?d00001 diff --git a/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Contents.json b/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Contents.json new file mode 100644 index 0000000..fe13228 --- /dev/null +++ b/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Contents.json @@ -0,0 +1,7 @@ +{ + "info" : { "author" : "xcode", "version" : 1 }, + "properties" : { + "frame-size" : { "height" : 768, "width" : 1280 }, + "frame-center" : { "x" : 640, "y" : 384 } + } +} \ No newline at end of file diff --git a/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json b/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json new file mode 100644 index 0000000..193b563 --- /dev/null +++ b/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json @@ -0,0 +1,6 @@ +{ + "images" : [ + { "filename" : "Middle.png", "idiom" : "tv", "scale" : "1x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} \ No newline at end of file diff --git a/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Content.imageset/Middle.png b/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Content.imageset/Middle.png new file mode 100644 index 0000000000000000000000000000000000000000..1c6f430f7a3d4907f3e4170b443fd0afb15ce955 GIT binary patch literal 11586 zcmeHNXH-+!x4(#hiV$@aL^P-qRE822l?YKl2Q?}dY@r6R1f&FP5WxWB2nb?{6qRNH zN2D3)NOK^lAc=w^AT0vLs-bR-5!n&;D*3eqpPj~K4sq61D`6Fog$_4Xo z&zpIBe3CJ~JI}~zTWQ*@inC8TPagev@XqGaK^ts|7zf0 z4g5c7p!iHojt zfO9#Qy~W4dB6KP(k?&imExl(nd2F;OszUL)#-2p6Z#LTF{#b8!t|xmP>);R}BIHQA zpr!rny6$-5(oQpSqs?DKUmJ&2l*v-bHD9~YxD{xJ?uF4k1^G`sEeJQoE5kNDz2qLP zsu;;o9eXhkzCMtN2=NX{DF~G6U>8;e^42V5+_AM>YteoGNZW8l+8pWVqk)@^#<~k; z0CDm)v$`+A{QK3H21dG%zHb^CN;bH3NA*&Cvn%^zYqAkLufeV7(Jw&hr@l~8k9R>| zg|YR*PGL;;Ul+RK&doL|QH?Ajo>xY4nY_q%Y&*xwFz&w*cX?9uY)O$&QLpOBhO`|T zqUJVw#*L5@Q+A8<YnAF837-D7xjzv~%xpS_FL4LKHLE)q;#TR^k6 zE1n<8$~kgu%+$BpD!*a(QpNUB-$U>f^IWAeE=nqF|JkS=qR!m;1^QTbYPRimHfpky81%QUeUvDl0Di&8YYTP0f}Fl zXxks{?Mqe0<15|Rb-SYZUGfgF?)Z>CFX*a2eR)t?pjhM|e|lqDAnQc8yU(X_Uf6F^ zb0EPsHDvS*i(VP4U=Aymla+U!vGkC*Ge=ftDZRcZoiLCE68gD86OG83rLr9AHwJqo zGJa1?%^gqVCQe(tdX_?s!06ituQGhz>UYnzE4$*u$%%|-I2{dbe*K4i{;q+m%YmZ7 zzg>;POBjwP659hkX?Y)}n%4KsMSFA180F9VtCVTo2ck6%!2mHXto`EV-{4crYJS#B z%1!O8*2<(0ffJY>&G``XY|hg$1p#3u+*>1OO}m{-cc|+{!Qh>YME-*P1rq^{TeTz`+LuI5hp zrV8KvU_N`-(nkzxX<3O2r#PH6>FGiIp0MY49eHpI_hEI z5Z1#xjg-RVEndtx`8u+0ltqDWKGkMg!^Xoq#?za=nRDKUky1Q{TD%}Ww?}=c^6h zYu65j@AT+SK6v>N!D|2`G&x*964U)aJ>HJcLYZ+sqvymVp#7hSk7LaH5yxU z70zj@T-+TIKX@;e$r zUM2E>`*$2EQL7Sis+%$ZFe@_gxuERSJ1fe zPc}jPs#<#!B&?MvVbe$MkTMJCvwoN@$FTPlkFvO{#x(9!WC~o&aN8?~Rd*j$G4Hpuv%2$;k zc&++l)IbH|aGx#IhUEIo4z2sS@ljfZ@i$eSM-5cL;S-<&WJ5rvpPATdoU@A?-@S-j zbGq?uwbzFTUtEoi4$l3pv@H|?K^ufQZIrUQe=GRsXf{Mz%h6KvtJO^-7dVvLBwF^D zd}3h?3N;p#q~cKb^fVH!`(VT3o<|U}EoGEWIDM#M)DL~6)HTIUFI3_SEX%Nhlskg2 zYmHBW6hrUu#r@Y@f9J)lF+L1Sm1_hF*Ux!NSX9{lxwnuPy2f}HTK`SZ8!CbIEuch; z!EnFcLM~}znkDN;NB(tD~Wc-6KY|?o^7!8p^X@9_lPpp1~C)H1SD<0J@@N&F6nX-Mfv2x zpIPD_*yeI8-T0#P{M$fqXgkT7AFUZgsz4ud-181ma*-1xWWp$yL~Bmg-+X@4a-a_a z4|!wh3mQEZVk^t@UM>VX72k)17^}|Fq*YxAv7O0_S}t4ytIG_@Z3?T02G$S;fJf1x zo>f7%a_kN`wy*fnca2h+CboMdiI(Tad%66g+SgG?`gDJ%y8g;bj}6!d(3cP2TMzbZ zdXqj2l23@puaEL{yxdQZ#yzjdce?@wm?9*>IZ7eAl(@Xdo$NKR$I1*UM7ScTpe3R- zq8Y$Ml!6C1arjZf4cFQ7P?WQ3>%AQ-z{FOO4toYVn;eaWI>3c>*YmYd{9YG=QMuCa zN~9YiZvMdwE7vXJlAL-WgFs#kXQ}~bg?(AstFtC{bK}jmvS!8&m)#Wa1?hcCEHlSR zgimnWsP`yY>q0~fEK^Nri2GTVCRvVn(<-}LzrZEiEuo}a=>~kI7_TP0M>}erwy-_z z$_V0jz$)cO!rXDe_#G0>Tw5S# z)JoYGB$~x9Q5OTqHp(+F6X((aBdCiVujpJ-;#>YHrbm}$Y$$lfK}gBCikC-#$CwQb zI3jPj4n_~1XV4n+@k*QamFK`JB)CHO#K`C|Ra*ut-eClOpVI|tr7KOeAR}k4t`OYr z`E9{e?PRq%D2R5qzBljYc{!bngS_J+6}?WWv)T>E99B)Ed?7Imt@;}$nytP z2%X?_JYuP}yMgPtIRGQjRK>YPfU#m0+eu8c!EA9)^*3kCnuQhD2Oqysqn4k){tcr3 zD+&}!-klrA)P=Nnh&Q6jN|s<~1A}EpXttU;6V*g_!|u?gudj}PB%hxWiRKj?vd|8W zwLtVw)8;0_hdjj73 zBq1W!)<*@x|1d0MGnN)DAfJ%(f zC~&gWxv}WrbP+v*nhv^UZH9#Y)&CGIdNO+6ze$JMH^CJaeBcl_fSoh8R_5LLiya_~ z`Nf@mQLyE$>=WQ$ljh|V#oP=P;UUzTwMk|D5_c`Q|B$m?=+@GTFVt$_Y;aj8!7x$b zHT!uOq}DMNGE4OoOIvsmypjyMj{01Z&3=GB$sZ=%XglQ=I|$CLfgo16sRf9mUJcmx z7xD#3G&t+2re($zvhi^CrZlg_C?@1BD?PUx6;~dkrEE)2@dlv}xai@A#gPg-z|gLo zL9T%i@>s?@1op~us5)RfCrQ+?$9R=yngEBD%7HhwKaxCH1G38>A>6=?Y6>5U6Er}A z-Zu%LZru!s*}Dqcf$7Fsbz<&ptXQ5mgk0SC-BEkYuW~5YpuT8*3sBG!aZPZ+eR6Y#KG!hN z87^*e@&HIs$sv9R_VN8k2{&EP+PthO#7H^%KizV!xyFWgqtFsc@IU8a;aBl zV9&SV!dD@N>PbW*|LQrmvK(eFCvxoxm%&L}_jSK&qlV1D+$%)7F8QIACHH{(d^LjY z9)mWqG#LQhgDRT{r-5;m>qfxb?e`W!ge+i4`PQ}6Fr>ZgPx(tO4=h@a6$g)uLEz9l zZ>NEMj~0JM_RUN3k(-4*pTYF)nYyR24mLO)K^KiZ2)s=tmqs=bjw9yJf*aTHv^9kQ zF&g~%HBBW{Kg2vqPY^Fi2Z(Pl&CV(iv2#~M>70#<6u0jgin~}#U>CstOZpqZ7O}Hk z^zxAS-5dy2RMOZjIAtE?v7s;(LNx%dlh!z)L2oFdvu+XQ$5VgjLeh(2K zo~wuzlY>gaCAT1~Nhy1npZe}lOJ-mLil+-cL{U#7O6NlWS@fYG89PB*PMr(cn^tvy zV`H6KyQK7{=t87tK9<>6tYly@*t1pT55bC2<V=0o-IP%mCSn0R|S+mX~#pH$7hi^k6H~pmZA3A1eeR(UE#n^)*DhPnz0$S3>1VmVj z2$4|$)8X(PDOOe%%P7yU_6-L>2W4Lifen0d_uSvvyAa|1s$`fl=&!|0l1fd&rpai0 zz9$>{0Y^ClY!7Tx(k*W~8haJg)ryX9MH|o9eED*RstEl5M!-6JdXXOI{djVE6`Jzs zz+cg+PI$di0e)BaH-3;%0rbO7oS}NZONVo{qt8Q{q2R*QpxFuYIJ75}#K4{ZSp*;y zi7(!TO7YKPE%cXS>Rjx-7kdr9wH`$jT5v>WK7cD`qoN;X(p`#$1+GMbB%MdaEG)y~Kby(LlK-w;&=>X~d>^#9#Op}891t%M0?^*X6{zuL%Y$Qv=gikdWe}8Yh`<*fVYuRO!ga1N3YrVPWeEM{R5@}3 z(#8iN7JZ$gs$eUTP1tmJs>dmsVA#}x&PO(CcotA%ApnS{0o|;h#Q#J*UKmltTSSL& z15uK7sy0HEO~gADST4d?uK{5YPMCm$o*WFbS@1?x>kvsa^>hI$fOcMaYMzJQV$kK02BR-J5vPdM95$zbkEc&R>#Q&wf@``l*I zF^uyHrJ#RmIjRJ)$APIC8dwoC&~1&Pp)nQnFbtjO(Yah4PjzKQ zd5)!tT>$Lp4&c1|RwCZXx6&|BUq-plqyqNbVQ^6ksv}pX5gxzq7yNvG95689#42;u zm;1#!z-11U^L{j&p9YDE4xec~fFz(N)P8~IQy4yxx}F5vu4c;vp5&u36!j^R`g1D4 z%+xznUqGJ@9?K=+$8nf>n)ii-1J~O)?s7q=^{c$xD}D#4d|B|7d${yU9Y@cZcu{f&rt%`c4&2xjBmy%EY`}X-5(2PJzJ@9V!2|IYUTjh~F4tcL z!>UzTE?-Ue!Qz+rJe33L#wbEy5z49aSA)UG#&zy4&1$K(Uo#x znrJA5-oV8VvPm2OV|FnTDgu>%Jk}OqeoDu+pTim_ECs-T(prQZOc4^kKeU4-MjeTK z@Rx?^W7{3N`%&=M&4+C`NPLJWdvTbbg<<7rI$?@HT~ss7LfEmBFc?pyEHorshqd|^m<}Rt z12-2d+s=V<{qfdR-j6m;>?n}sE?#hgp1W=VY7pg!cQ70H>@XjYtdr6UMCBTMTt7gdjZX^P3gAk< zXz+6#$PCI?LO@r2Pe)|C`72ux7*aR{7ijc>0o^N+1_X}kcE|i{0@-KaC2pzo2{fDe zYB?9fj{c2r?maa~!2B}p(ScnZI43LKEHLf)bCwgbn7wZ9GQg)@W4XPM*)vr_{P4!< z1(;>*8jL>pV7;0q$fUY6Kpm3~@DyS9q@xa=@M|n|X2b$|J0@=p0@-TW#d35K%JtF2 zCKLSGawceL%mbz?>UAmUf9Od{KSzTb#tsCgkbY5=&3 z%e!MU8X&bm1N^ZJFKsY)GWj(cUS&>y8o5mT1BmP2Lvu)h7qE7<*)R-k2Xq<@^D62* zK&=~dy=EJtHmMFKVoS8#@}f|<*h3!w_~v`-2V{o5GZFF&w0pS%uD`Lc$Zf13j=2K- zEfnxd&iXZo%L6<+jKd^b8&&YKhqY`+2=MxkXn4%-kU^953R9TYzob%u-qpgR?lpSU z6oO-!Xtl$n8{T$e6r3Oq%pGAArH7!j2Zs5b#AkGwU|U1g$SA%hlbtF9we44d+(h2h z4t3cq_>#AM2;8U0ZuznUJTR3^MR-= z17HxQD}oP43B22O^7yL=&0xwTH+X#!C(l3~aLy6Pz{K_yHsFAZP&^*dxzB z8fD2GSWoy0Te9mw_DOi76Rv`RsO~l0S8lW6?%B3WO&u!8!02}E0xEKOQ|OAjc03E* znKdy3D_#vA3=(*#n(X<$@jdRr9-`w2J+tU$;L+K;?LK8aqK6+s)n9la5H;^T?_-> zv12iBTVGo|qwawZ>MNT%URzIUmlQ&(y5QjtGgPl=CJd|WWk;8Q_fKjwCc%sJ%{?#f zaGMiBN-W^3y%LPUC@ml)>JQV&Q}6{5^;_e1j*0V z=aU9wxU<;{e89JK)5BW5lh%;lqdw&uaXPrLwdF6E(glB+At|{EO?|q{Fa%C&8@P6k zE}a{{QRhZ)ErfTo8|-gJ71Ij_KDO+UTO^vc?u{-A3L4f%uvaF3)DOWF{3&GzcvEj@ zG(@t!rpTulnby(UVf?wia-#Gb;{oKW3pcjS6M+4$#LAvDpLEu4a!}AqG|zr&d6(l9k_3auf{d zSH$1e0tWkr&_^JozfB2V4>JJ>=ncb-;3aKVGz>sTAHoH5e$(x~Sl0(bHwzYjk8O?n!(xhug7!N%UX zjMC8HQPaHu)ll2dfgWut6ygwq?bUY=0atN5MCTg4pYD$#rr_dK-YuWA)&e4>Ti~a%DAX6Mk!GXAZ!ayp99(y4m@rl&n8(+3sP`Ot^`` z0a{)}KY}Uch)jpBonc1%LfLMMQ{x!T{On=x>F)JnlRv*%q}c6(VzvvS50JC0`w} z;5$4ZP5KNbz_XT@ngNdi^Cqb++`}(((U(7^hWhX2R&sx&fw$-D@CrcRer0zfCYvC& zP+|1NV9$$)SJ?napzY{|>|wC$doOTRlzL%sek9s6T!X8bxDH0C2mpG`=1Nt7#zec6 zeaxfw^3qHF4JNcGJ40bVXDSMU4oew^mmYv7_DRp<&7Er03$97_we9#ZVF1YzoOHx+ zt@4{VP?@MFc>-RiJT10RgB7Ww?o*6Wq(i0PblDTuF};6tkJg>FW<}e6*MXqN=nv6v zsYG41UA3~Stb4C=8)b&UPmrT{iDj(DVnUzFOwGyN9(~^c9hPoJW_@lds)!lcwl=G; zZoQ6^rAwDBr&X1#EN(_-wBOE;XiZ~!T?inH=1KF{EKj`0SM>9mKLAtj>;%vdW_6OC z7-Sug5O9QW)8S!r!ZKJZE<%$$fi&3Fe=ze_+RTKZ{_dYsFkln(Bg9hrH)pDO6Qc7( z<4P5|6CNwv)VHGN3Yl$HXI|7I?If4B!>^rOcYhUhUcxt9RuRQU57D|m;p&i+R91^Z z;_1mjy(NN$VVuH|<~=^gU)uDXbJKv&&)12{KG5IOIlA9$Of&N?d7WoJ6o$fV4>LJ& z8Dg^Gz!Jq3yvh%Cf1EkYdp|jBdD)}-;>38Y)Bs4J^{=oFid4M93#X_kwCQAME9z7& zQ)M#BisI0?uwu_ zOKt*~&KJD{Cz`n6{~REXGjA%X{g_WhZyp$bo|MQh+CSR74jo-nd)9mKGe72;uRA-0 zryinSFYYcVA~(`GuT+tyq(+FNiK32b5CSC?W{=yqJtWWRFQg~iPNfI=>Q(se7;NuM z;1_t0#%Uvt6E8-BU2?xI)Kz_4XgBM_y`@v>2R^A6yj8Sx)ziCRIpvw|!Y;F07?Kt# z^{$i_K>mRjKYFaSS8luJ>f8#(il!r@(!VNfr)Kn@&8kPI|NHZ=2L9E+|EUIw8^?Sn X_nw`pKk8xztcEC)EiDz2^FJwXHKg9f@v?_52YSwAv56fq~R?Mh?VaJI2Lv-@D!@W12p zhl`}ADoqk=i4JWy=D$O+US7@9c+A~BzZb;lY0Si-dAqFr^zu)QiSd*8ZO z+)VF+<4x0+M%164g)S!Wl3Um2ET*Hjt?jRCKo=qtH>0CNIR)Km!zJ4?EZ`m=-I@9) znWlZuYHhgd%?z2L!N`FbvxBM|19!_tU+BO)4UosSS%ZCHXCdT z8|>e&dRStn%2eHYm1XaopUt)~8BB6qQ8O9b7xlqpmUJ0tstIR`+FR?}3jXpI02#K0 zaEInP=6Ul^E=zH8$e{o^h7fYdWu6m|Bf?7#*~ANP!O20G79)pD?c+BCCr9tM+TBypp1^V$ECa{co*I>a&@bxYRK}I&)eI1)<~vlOSH+# z1H?W<>aWmQ`8!lk2)xZ2)q zhieAQI?R@6ttrG)$B7YcY96pTKlT)80!R1;t&NjI1FP9Ix5sHaD!&6o#Q}k zDFq`aL%4rXY`tlBJOSEHa@p;DL+heX_=;{neODKL@8a`~QkzOUxXrn zY1Smsrb@EA^qVPZ-uJG4=>~Fe4f{coQ#^jHb|r15>6xJ5Q)Y5&W2Vs-J_71IB@o(+ z>61SVEVf8JW@aiV@07ji%FxeVKu*KcM?h7ld2^Xl(-v+7*-S~C+EcD2G&41P4Ihc# zY?^XXtGwpDTze!uLDA{$Jy2_S{%CW>`_?Ui@0P`7?e{ceIZsnCqngmODrI`D>Kvxm zL7O)_lQW(x(5qT>`XYbTi~ppl`GZz2V}oT$m!>h-t283wm@?+H**5uBcBPw};V;GU zpO`KECtYfajXGOwFvZAsuEt}l3oqGsIgy4?h1M#p$6(QoZh$4h95736!`vTv%`+SL}wU`8_et%$%co~+1dJG=OZrvi>{$92}f?8 z{aU4bdyP!HqfO9({9O}1fvb&oTeQicveiX6CB>rmVk@@`2r5*ACP5CO!xDMbhz?uB z22-=amaxHMIz?SJP`O27<~-;WBp;+a!6J&uSkYhq^0sWLM-A?e%9UOY_|J_5qvoq$ z;ad^^T}09)ulHyn(>^~oJxBhQ1=(g)&Do`!By$WR(-O#mpa41gN0LMi=^zq10s=WOgYx9SVTtac{1e6|)!)w4MDT}G3iZ;}2~{t@ G{?UKvIUGO$ literal 0 HcmV?d00001 diff --git a/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Back@2x.png b/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Back@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..fe8739e6a523c9c45cb68f56af82e955e23b209f GIT binary patch literal 27408 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 Dr}3dq literal 0 HcmV?d00001 diff --git a/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Contents.json b/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Contents.json new file mode 100644 index 0000000..7a30c7c --- /dev/null +++ b/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Contents.json @@ -0,0 +1,7 @@ +{ + "images" : [ + { "filename" : "Back.png", "idiom" : "tv", "scale" : "1x" }, + { "filename" : "Back@2x.png", "idiom" : "tv", "scale" : "2x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} \ No newline at end of file diff --git a/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Contents.json b/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Contents.json new file mode 100644 index 0000000..445ea7a --- /dev/null +++ b/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Contents.json @@ -0,0 +1,7 @@ +{ + "info" : { "author" : "xcode", "version" : 1 }, + "properties" : { + "frame-size" : { "height" : 240, "width" : 400 }, + "frame-center" : { "x" : 200, "y" : 120 } + } +} \ No newline at end of file diff --git a/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Contents.json b/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Contents.json new file mode 100644 index 0000000..b6ff329 --- /dev/null +++ b/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Contents.json @@ -0,0 +1,8 @@ +{ + "info" : { "author" : "xcode", "version" : 1 }, + "layers" : [ + { "filename" : "Front.imagestacklayer" }, + { "filename" : "Middle.imagestacklayer" }, + { "filename" : "Back.imagestacklayer" } + ] +} \ No newline at end of file diff --git a/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Contents.json b/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Contents.json new file mode 100644 index 0000000..17fa9b0 --- /dev/null +++ b/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Contents.json @@ -0,0 +1,7 @@ +{ + "images" : [ + { "filename" : "Front.png", "idiom" : "tv", "scale" : "1x" }, + { "filename" : "Front@2x.png", "idiom" : "tv", "scale" : "2x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} \ No newline at end of file diff --git a/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Front.png b/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Front.png new file mode 100644 index 0000000000000000000000000000000000000000..63270b7b6c592bd4f3744452396fc8e53b0165a6 GIT binary patch literal 2259 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 ick-VaS{P_P!X6A|j literal 0 HcmV?d00001 diff --git a/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Front@2x.png b/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Front@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..e593365a43889641322d0a2a84c1b494bbce0e48 GIT binary patch literal 5271 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 H)fLo&$RT0DvqK0e1%g z#O?{@@4KXhyD`*ETgb#B+@0(JHT`N_p&}UoI}HN>>c}$Nm!yRH?yCro2mnCtjcAE= zA)^`;{9> z&GkRWC+Bu1=K_!Xnhb;{?^h?!2;yz?evL#!o{k6vJIQi~26B?K$sOMWVVeQl=yM%Y zwzdExtLkXlH$2{?AEU;(QRvF?<}NxMGEOLdvB};xm$)hw?x~lACI7MT^R=I#w5Yuw zGWMjh92g#+Q7jmY-HG?ITpg1KjNxqy??!_Q_eNt9o~z*d3sLI<5i+=1Cf?r%mnN3@ zs=&Ko@2o4in|fgrUO`ter^dV)F{dh)T&%;%tK4op_P{k2eQAB_h_PN>k>4%xNcUbd z8*)H=xbq7-iB*WNWeQNP;JiYBs!@@q8J#)aKH%c_OGhv&R^{D$)Q>mn@Dw}z;LYR& zC*#rX+Veh@^Dc&8-(kyUT~5t=)$P-usg9i=))RP2RKX2<-#OzMM?V-bsyMrww(+2= zS9m(E+tCMT(3Kv?Y2{_k+v=X&uo0sy80)eMl0zcUE z24vMaYtqc;0qS!(Wa!<%WCJc`Q%`C8VA;&9bVeyj*pav^*R5+xL#_-M#+kIgXCy$j zQpL2n9VE|o-urm)nUAVAP6q6f*M7ES{kfYnm8k?fM6-LjXA5YBnaUA0?$m2dZg3Zg zMhJy{a;aojL-*-;o0Hc+LwIIGO)yO^x+~O%Y^vuD?Z8ptLG>dL5~lV_#;dK+aC;MG z*adVV&*mX*P(KgkfLxk>b4IbxqW{^(ifmM-{~)}gYrf|aI0AKJCM2yPGzZ-$nV!6q1B!mo` zGt?kc7`aPq{S2qrXY12$m`nv=q|JNX`ay2stQ$D(qepQ!we7b@?CfEYgZaLo#HJKf z*2N(@u&FfqrL?^pr|y5hEe5OEK*C=800Ib(_Tdd-MecR=_M&hPEI#Sq#a~A(a6`_q zdnQ3vOCtOOn@fk4^Rs8_fQ2z;1-5;_meS#9GbZ7FH#ial-A@5UBP*BwwkG6?bXLyq zdu7!ep}j+`oOXTAIw;d$9Ug45prP1jMI6E~Z1vEsDE?ziYY(u!G+$}iqpkXdoA9l& zglIAov`#E@ZO{qPyfM5{UVb zQT2~Snx5OCsuT0FU%i}7BF5#CfK@3Z z;#Rhh-xG2Q3HYkh0sd^3BW9FIA{x1#_2Y%9semq~F;5wT>?$gMqtaCFm7Yx*cJz;^ zCT7vp>D|X`rR!qOvn={0&V1$1_e_)rCs}9=b{_Char^S}o0#gMje(WKsIWNR z?%9MzancAMs8h>6x6ARKknv8?t_S{D0r7OUVS^0j;{>PBI}lv3x~RYjt{LN5*A+h;kE?7 zf~hs^*OE8SeO=Dmjv-HpUr-=pzdo-k@A_gwqp57>0vaj)qx^~OAl5s}m?v(gT~^GN ziA9fAi!8EOiYjUBBw2dUwv~->F{Q6vg7}7Uieg!+cwCg%Rrk;SDj{A%{8*DXozptO>pdYNhMU)^rBADS~$$ZO>`7|gbs+)RpwyR#~V#FNIV?jS7W1Y)X}1j(4F=M znu>C#SU)jlUp6(%(qm&Ye1gB3LnXNmZ{v1a#K$2Vi>2I(w;4wnXE<-sABwzr*Zd&{6uq-QKgV>Loj+YHl_IN1OW~BZ u8R8xoxz1VW@0L6XA|oAK;5GKXss9JTZ;xpJ literal 0 HcmV?d00001 diff --git a/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Middle@2x.png b/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Middle@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..c6d7f53bfc2a16b695373f7cb954584b9767f55a GIT binary patch literal 5954 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 literal 0 HcmV?d00001 diff --git a/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/TopShelfWide@2x.png b/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/TopShelfWide@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..fb68d35a3657eb99a2036a723f813c728d66220b GIT binary patch literal 243151 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 literal 0 HcmV?d00001 diff --git a/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Contents.json b/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Contents.json new file mode 100644 index 0000000..c0741cc --- /dev/null +++ b/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Contents.json @@ -0,0 +1,7 @@ +{ + "images" : [ + { "filename" : "TopShelf.png", "idiom" : "tv", "scale" : "1x" }, + { "filename" : "TopShelf@2x.png", "idiom" : "tv", "scale" : "2x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} \ No newline at end of file diff --git a/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/TopShelf.png b/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/TopShelf.png new file mode 100644 index 0000000000000000000000000000000000000000..db6122a5d37af93493a3868e8a23a1d315b363e8 GIT binary patch literal 104304 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 literal 0 HcmV?d00001 diff --git a/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/TopShelf@2x.png b/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/TopShelf@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..5ec0748d76f6117062f212370abc023ee0b38cda GIT binary patch literal 242395 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

iYa=1kV=TGa~=talZ8v97{%g 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 literal 0 HcmV?d00001 diff --git a/apps/ios/VMCTV/Assets.xcassets/Contents.json b/apps/ios/VMCTV/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/apps/ios/VMCTV/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/VMCTV/Data/ApiClient.swift b/apps/ios/VMCTV/Data/ApiClient.swift new file mode 100644 index 0000000..5e7f8eb --- /dev/null +++ b/apps/ios/VMCTV/Data/ApiClient.swift @@ -0,0 +1,394 @@ +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": "apple_tv", + ], 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 login(email: String, password: String, deviceName: String) async throws -> (refreshToken: String, viewerName: String) { + struct LoginResponse: Decodable { + let refreshToken: String + let viewer: ViewerLite? + struct ViewerLite: Decodable { + let name: String? + let email: String? + } + } + let res: LoginResponse = try await post("/api/v1/client/login", json: [ + "email": email, + "password": password, + "deviceName": deviceName, + "platform": "apple_tv", + ], authed: false) + let name = res.viewer?.name ?? res.viewer?.email ?? "Kijker" + return (res.refreshToken, name) + } + + 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, + sort: String? = nil, + letter: String? = nil + ) async throws -> BrowsePayload { + let id = catalogId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? catalogId + var q = "skip=\(skip)&take=\(take)" + if let sort, !sort.isEmpty { + q += "&sort=\(sort.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? sort)" + } + if let letter, !letter.isEmpty { + q += "&letter=\(letter.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? letter)" + } + return try await get("/api/v1/client/catalog/\(id)?\(q)") + } + + 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 nextEpisode(episodeId: String) async throws -> NextEpisode? { + let encoded = episodeId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? episodeId + struct Wrap: Decodable { let next: NextEpisode? } + let wrap: Wrap = try await get("/api/v1/client/episodes/\(encoded)/next") + return wrap.next + } + + 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 markWatched(movieId: String? = nil, episodeId: String? = nil) async throws { + var body: [String: Any] = [:] + if let movieId { body["movieId"] = movieId } + if let episodeId { body["episodeId"] = episodeId } + let _: Empty = try await post("/api/v1/client/progress/mark-watched", json: body) + } + + func dismissContinue(progressId: String) async throws { + let encoded = progressId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? progressId + let _: Empty = try await post("/api/v1/client/progress/\(encoded)/dismiss", json: [:]) + } + + 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, + showAvatar: Bool? = nil, + clearAvatarImage: Bool = false, + 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 showAvatar { body["showAvatar"] = showAvatar } + if clearAvatarImage { body["clearAvatarImage"] = true } + 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 uploadProfileAvatar(profileId: String, imageBase64: String, mimeType: String = "image/jpeg") async throws -> ViewerProfile { + let encoded = profileId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? profileId + struct Wrap: Decodable { let profile: ViewerProfile } + let wrap: Wrap = try await post("/api/v1/client/profiles/\(encoded)/avatar", json: [ + "imageBase64": imageBase64, + "mimeType": mimeType, + ]) + return wrap.profile + } + + func absoluteUrl(_ path: String?) -> URL? { + guard let path, !path.isEmpty else { return nil } + if path.hasPrefix("http") { return URL(string: path) } + return URL(string: baseUrl + path) + } + + func bearerToken() -> String? { token } + + 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 listDevices() async throws -> [LinkedDevice] { + let payload: DevicesPayload = try await get("/api/v1/client/devices") + return payload.devices + } + + func revokeDevice(deviceId: String) async throws { + let encoded = deviceId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? deviceId + let _: Empty = try await post("/api/v1/client/devices/\(encoded)/revoke", json: [:]) + } + + func approveDeviceCode(_ code: String) async throws -> ApproveDeviceResult { + try await post("/api/v1/client/device/approve", json: ["code": code]) + } + + 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") + } + + func iptvEpg(streamId: Int) async throws -> IptvEpg { + try await get("/api/v1/client/iptv/channels/\(streamId)/epg") + } + + // MARK: - HTTP + + private struct Empty: Decodable {} + + private func get(_ path: String, authed: Bool = true) async throws -> T { + let data = try await getData(path, authed: authed) + return try JSONDecoder().decode(T.self, from: data) + } + + private func post(_ path: String, json: [String: Any], authed: Bool = true) async throws -> T { + let data = try await requestData(path, method: "POST", json: json, authed: authed) + if T.self == Empty.self { return Empty() as! T } + if data.isEmpty { throw ApiError.message("Leeg antwoord") } + return try JSONDecoder().decode(T.self, from: data) + } + + private func patch(_ path: String, json: [String: Any], authed: Bool = true) async throws -> T { + let data = try await requestData(path, method: "PATCH", json: json, authed: authed) + return try JSONDecoder().decode(T.self, from: data) + } + + private func requestEmpty(_ path: String, method: String) async throws -> Empty { + _ = try await requestData(path, method: method, json: nil, authed: true) + return Empty() + } + + private func getData(_ path: String, authed: Bool) async throws -> Data { + try await requestData(path, method: "GET", json: nil, authed: authed) + } + + private func requestData( + _ path: String, + method: String, + json: [String: Any]?, + authed: Bool + ) async throws -> Data { + guard let url = URL(string: baseUrl + path) else { + throw ApiError.message("Ongeldige URL") + } + var req = URLRequest(url: url) + req.httpMethod = method + req.setValue("application/json", forHTTPHeaderField: "Accept") + if let json { + req.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type") + req.httpBody = try JSONSerialization.data(withJSONObject: json) + } + if authed, let token, !token.isEmpty { + req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + let (data, response) = try await session.data(for: req) + guard let http = response as? HTTPURLResponse else { + throw ApiError.message("Geen HTTP-antwoord") + } + guard (200..<300).contains(http.statusCode) else { + let msg = (try? JSONDecoder().decode(ApiErrorBody.self, from: data))?.error?.message + ?? String(data: data, encoding: .utf8) + ?? "HTTP \(http.statusCode)" + throw ApiError.badStatus(http.statusCode, msg) + } + return data + } +} diff --git a/apps/ios/VMCTV/Data/AppModel.swift b/apps/ios/VMCTV/Data/AppModel.swift new file mode 100644 index 0000000..039fa4c --- /dev/null +++ b/apps/ios/VMCTV/Data/AppModel.swift @@ -0,0 +1,163 @@ +import Foundation +import UIKit +import Combine + +@MainActor +final class AppModel: ObservableObject { + enum Phase: Equatable { + case boot + case server + case auth + case password + case link + case profiles + case main + } + + enum MainTab: String, CaseIterable, Identifiable { + case home, watchlist, movies, series, tv, search, settings + var id: String { rawValue } + + var title: String { + switch self { + case .home: return "Home" + case .watchlist: return "Watchlist" + case .movies: return "Films" + case .series: return "Series" + case .tv: return "TV" + case .search: return "Zoeken" + case .settings: return "Instellingen" + } + } + + var icon: String { + switch self { + case .home: return "house.fill" + case .watchlist: return "bookmark.fill" + case .movies: return "film" + case .series: return "rectangle.stack.fill" + case .tv: return "tv.fill" + case .search: return "magnifyingglass" + case .settings: return "gearshape.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? + @Published private(set) var playbackDepth = 0 + + var isPlaybackActive: Bool { playbackDepth > 0 } + + func enterPlayback() { playbackDepth += 1 } + func leavePlayback() { playbackDepth = max(0, playbackDepth - 1) } + + private var cancellables = Set() + + init() { + favorites.objectWillChange + .receive(on: RunLoop.main) + .sink { [weak self] _ in self?.objectWillChange.send() } + .store(in: &cancellables) + Task { await bootstrap() } + } + + func bootstrap() async { + phase = .boot + bootError = nil + await api.configure(baseUrl: store.apiBase, token: store.refreshToken) + guard store.isAuthed else { + phase = .server + 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, + showAvatar: profile.showsAvatar, + avatarUrl: profile.avatarUrl ?? "" + ) + 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 = .server + } + } + + 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, + showAvatar: profile.showsAvatar, + avatarUrl: profile.avatarUrl ?? "" + ) + 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 = .server + } + + static var deviceName: String { + UIDevice.current.name.isEmpty ? "Apple TV" : UIDevice.current.name + } +} diff --git a/apps/ios/VMCTV/Data/FavoritesStore.swift b/apps/ios/VMCTV/Data/FavoritesStore.swift new file mode 100644 index 0000000..a6ee2de --- /dev/null +++ b/apps/ios/VMCTV/Data/FavoritesStore.swift @@ -0,0 +1,143 @@ +import Foundation +import SwiftUI + +@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.tv.favorites.cache.\(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() + } + + @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 { + await refresh(using: api) + } + return result.favorited + } catch { + 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 { + let remote = try await api.listFavorites() + replaceAll(remote) + } catch { + if items.isEmpty { + items = loadCache(profileId: profileId) + } + } + } + + var mediaItems: [MediaItem] { items } + + 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)) + } + } +} diff --git a/apps/ios/VMCTV/Data/Models.swift b/apps/ios/VMCTV/Data/Models.swift new file mode 100644 index 0000000..5520a25 --- /dev/null +++ b/apps/ios/VMCTV/Data/Models.swift @@ -0,0 +1,347 @@ +import Foundation +import CoreGraphics + +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? + let defaultSubtitle: DefaultSubtitle? +} + +struct DefaultSubtitle: Decodable { + let fileId: Int? + let source: String? + let language: String? + let fileName: String? + let format: String? + let contentBase64: String + let label: String? +} + +struct ViewerProfile: Decodable, Identifiable, Hashable { + let id: String + let name: String + let avatarId: String? + let isOwner: Bool? + let isKids: Bool? + let hasPin: Bool? + let showAvatar: Bool? + let hasCustomAvatar: Bool? + let avatarUrl: String? + + var showsAvatar: Bool { showAvatar ?? true } + var hasCustomPhoto: Bool { hasCustomAvatar == true } +} + +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? + let deviceId: String? + + var iptvEnabled: Bool { iptv?.enabled == true } +} + +struct LinkedDevice: Decodable, Identifiable, Hashable { + let id: String + let name: String + let platform: String? + let lastSeenAt: String? + let createdAt: String? + let current: Bool? +} + +struct DevicesPayload: Decodable { + let devices: [LinkedDevice] +} + +struct ApproveDeviceResult: Decodable { + let ok: Bool? + let deviceName: String? + let platform: String? +} + +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? + let completed: Bool? + let positionMs: Int64? + let durationMs: Int64? + + var progressFraction: CGFloat { + if completed == true { return 1 } + guard let dur = durationMs, dur > 0, let pos = positionMs else { return 0 } + return CGFloat(pos) / CGFloat(dur) + } + + var resumeMs: Int64 { + if completed == true { return 0 } + return max(0, positionMs ?? 0) + } +} + +struct NextEpisode: Decodable, Identifiable, Hashable { + let id: String + let seriesId: String + let seasonNumber: Int? + let episodeNumber: Int? + let title: String? + let seriesTitle: String? + let subtitle: String? + let mediaFileId: String + let posterUrl: String? +} + +struct SearchPayload: Decodable { + let results: [MediaItem] +} + +struct BrowsePayload: Decodable { + let id: String? + let title: String? + let kind: String? + let total: Int? + let offset: 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? + let nextTitle: 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 EpgProgram: Decodable, Hashable { + let title: String + let start: String? + let end: String? + + private static func parseISO(_ raw: String?) -> Date? { + guard let raw, !raw.isEmpty else { return nil } + if let d = ISO8601DateFormatter.vmcFractional.date(from: raw) { return d } + return ISO8601DateFormatter.vmcBasic.date(from: raw) + } + + var progress: CGFloat { + guard let s = Self.parseISO(start), let e = Self.parseISO(end) else { return 0 } + let total = e.timeIntervalSince(s) + guard total > 0 else { return 0 } + return CGFloat(min(1, max(0, Date().timeIntervalSince(s) / total))) + } + + var timeRangeLabel: String? { + guard let s = Self.parseISO(start), let e = Self.parseISO(end) else { return nil } + let f = DateFormatter() + f.locale = Locale(identifier: "nl_NL") + f.dateFormat = "HH:mm" + return "\(f.string(from: s)) – \(f.string(from: e))" + } +} + +struct IptvEpg: Decodable, Hashable { + let streamId: Int + let channelName: String + let logoUrl: String? + let now: EpgProgram? + let next: EpgProgram? +} + +extension ISO8601DateFormatter { + static let vmcFractional: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return f + }() + + static let vmcBasic: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime] + return f + }() +} + +struct ShelfPref: Codable, Identifiable, Hashable { + var id: String { shelfId } + let shelfId: String + let name: String + let slug: String? + let kind: String? + var visible: Bool + var sortOrder: Int +} + +struct FavoriteToggleResult: Decodable { + let favorited: Bool + let items: [MediaItem]? +} + +struct ApiErrorBody: Decodable { + struct ErrorObj: Decodable { let message: String? } + let error: ErrorObj? +} + +struct PlayerTrack: Identifiable, Hashable { + let id: Int32 + let name: String +} diff --git a/apps/ios/VMCTV/Data/SessionStore.swift b/apps/ios/VMCTV/Data/SessionStore.swift new file mode 100644 index 0000000..fe7bd83 --- /dev/null +++ b/apps/ios/VMCTV/Data/SessionStore.swift @@ -0,0 +1,127 @@ +import Foundation + +@MainActor +final class SessionStore: ObservableObject { + /// Readable from ApiClient (actor) without MainActor hop. + nonisolated 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? + @Published var profileShowAvatar: Bool = true + @Published var profileAvatarUrl: String? + @Published var iptvFavoriteIds: Set = [] + @Published var lastIptvStreamId: Int? + + private let defaults = UserDefaults.standard + private enum Key { + static let token = "vmc.tv.refresh_token" + static let api = "vmc.tv.api_base" + static let profileId = "vmc.tv.profile_id" + static let profileName = "vmc.tv.profile_name" + static let profileAvatar = "vmc.tv.profile_avatar" + static let profileShowAvatar = "vmc.tv.profile_show_avatar" + static let profileAvatarUrl = "vmc.tv.profile_avatar_url" + static let iptvFavorites = "vmc.tv.iptv.favorites" + static let iptvLastStream = "vmc.tv.iptv.last_stream" + } + + 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) + let showRaw = defaults.string(forKey: Key.profileShowAvatar) + profileShowAvatar = showRaw != "0" + profileAvatarUrl = defaults.string(forKey: Key.profileAvatarUrl) + let favs = defaults.array(forKey: Key.iptvFavorites) as? [Int] ?? [] + iptvFavoriteIds = Set(favs) + let last = defaults.integer(forKey: Key.iptvLastStream) + lastIptvStreamId = last > 0 ? last : nil + } + + 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?, + showAvatar: Bool? = nil, + avatarUrl: String? = nil + ) { + 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) } + if let showAvatar { + profileShowAvatar = showAvatar + defaults.set(showAvatar ? "1" : "0", forKey: Key.profileShowAvatar) + } + if let avatarUrl { + if avatarUrl.isEmpty { + profileAvatarUrl = nil + defaults.removeObject(forKey: Key.profileAvatarUrl) + } else { + profileAvatarUrl = avatarUrl + defaults.set(avatarUrl, forKey: Key.profileAvatarUrl) + } + } + } else { + defaults.removeObject(forKey: Key.profileId) + defaults.removeObject(forKey: Key.profileName) + defaults.removeObject(forKey: Key.profileAvatar) + defaults.removeObject(forKey: Key.profileShowAvatar) + defaults.removeObject(forKey: Key.profileAvatarUrl) + profileName = nil + profileAvatarId = nil + profileShowAvatar = true + profileAvatarUrl = nil + } + } + + func isIptvFavorite(_ streamId: Int) -> Bool { + iptvFavoriteIds.contains(streamId) + } + + @discardableResult + func toggleIptvFavorite(_ streamId: Int) -> Bool { + if iptvFavoriteIds.contains(streamId) { + iptvFavoriteIds.remove(streamId) + } else { + iptvFavoriteIds.insert(streamId) + } + defaults.set(Array(iptvFavoriteIds), forKey: Key.iptvFavorites) + return iptvFavoriteIds.contains(streamId) + } + + func setLastIptvStream(_ streamId: Int) { + lastIptvStreamId = streamId + defaults.set(streamId, forKey: Key.iptvLastStream) + } +} diff --git a/apps/ios/VMCTV/Info.plist b/apps/ios/VMCTV/Info.plist new file mode 100644 index 0000000..33f0135 --- /dev/null +++ b/apps/ios/VMCTV/Info.plist @@ -0,0 +1,48 @@ + + + + + CFBundleDevelopmentRegion + nl + CFBundleDisplayName + VMC + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSRequiresIPhoneOS + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSAllowsArbitraryLoadsForMedia + + NSAllowsLocalNetworking + + + NSLocalNetworkUsageDescription + VMC speelt media af vanaf je Media Cluster op het lokale netwerk. + NSBonjourServices + + _http._tcp + _https._tcp + + UIRequiredDeviceCapabilities + + arm64 + + UIUserInterfaceStyle + Dark + + diff --git a/apps/ios/VMCTV/Playback/TVPlayerView.swift b/apps/ios/VMCTV/Playback/TVPlayerView.swift new file mode 100644 index 0000000..a37b6ac --- /dev/null +++ b/apps/ios/VMCTV/Playback/TVPlayerView.swift @@ -0,0 +1,823 @@ +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? + let subtitleFileURL: URL? + let logoUrl: String? + /// Live-only context for zap / zenderlijst / EPG. + let liveStreamId: Int? + let liveChannels: [IptvChannel] + let liveCategoryId: String? + + static func vod( + url: URL, + title: String, + mediaType: String, + mediaFileId: String, + movieId: String?, + episodeId: String?, + seriesId: String?, + startMs: Int64, + subtitleFileURL: URL? = nil, + logoUrl: String? = nil + ) -> PlayRequest { + PlayRequest( + url: url, title: title, mediaType: mediaType, mediaFileId: mediaFileId, + movieId: movieId, episodeId: episodeId, seriesId: seriesId, + startMs: startMs, isLive: false, fallbackUrl: nil, + subtitleFileURL: subtitleFileURL, logoUrl: logoUrl, + liveStreamId: nil, liveChannels: [], liveCategoryId: nil + ) + } + + static func live( + url: URL, + title: String, + fallback: URL?, + logoUrl: String? = nil, + streamId: Int? = nil, + channels: [IptvChannel] = [], + categoryId: String? = nil + ) -> PlayRequest { + PlayRequest( + url: url, title: title, mediaType: "live", mediaFileId: nil, + movieId: nil, episodeId: nil, seriesId: nil, + startMs: 0, isLive: true, fallbackUrl: fallback, + subtitleFileURL: nil, logoUrl: logoUrl, + liveStreamId: streamId, liveChannels: channels, liveCategoryId: categoryId + ) + } +} + +private enum PlayerPanel { + case none + case tracks + case subtitles + case liveGuide +} + +private enum PlayerControlFocus: Hashable { + case audio + case captions + case rewind + case play + case forward +} + +struct TVPlayerView: 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 panel: PlayerPanel = .none + @State private var subtitleHits: [SubtitleHit] = [] + @State private var subsLoading = false + @State private var subsError: String? + @State private var progressTask: Task? + @State private var hideTask: Task? + @FocusState private var focusedControl: PlayerControlFocus? + @State private var liveStreamId: Int? + @State private var liveTitle: String = "" + @State private var liveLogoUrl: String? + @State private var liveEpg: IptvEpg? + @State private var liveZapBusy = false + + var body: some View { + ZStack { + Color.black.ignoresSafeArea() + VLCVideoView(controller: player) + .ignoresSafeArea() + + if controlsVisible && panel == .none { + controlsOverlay + .transition(.opacity) + } else if panel == .none { + Color.clear + .contentShape(Rectangle()) + .focusable() + .onMoveCommand { direction in + if request.isLive { + handleLiveMove(direction) + } else { + showControls() + focusedControl = .play + switch direction { + case .left: player.seek(byMs: -10_000) + case .right: player.seek(byMs: 30_000) + default: break + } + } + } + } + + if panel == .tracks { + tracksPanel + .transition(.move(edge: .trailing).combined(with: .opacity)) + } + + if panel == .subtitles { + subtitlesPanel + .transition(.move(edge: .trailing).combined(with: .opacity)) + } + + if panel == .liveGuide { + liveGuidePanel + .transition(.move(edge: .trailing).combined(with: .opacity)) + } + + if player.hasError { + errorOverlay(player.errorMessage ?? "Afspeelfout") + } + } + .onPlayPauseCommand { + player.togglePlay() + showControls() + focusedControl = .play + } + .onExitCommand { + if panel != .none { + withAnimation(.easeOut(duration: 0.2)) { panel = .none } + showControls() + focusedControl = .play + } else if controlsVisible { + hideControls() + } else { + dismiss() + } + } + .onAppear { app.enterPlayback() } + .task { + await start() + focusedControl = .play + scheduleHide() + } + .onDisappear { + app.leavePlayback() + hideTask?.cancel() + progressTask?.cancel() + Task { await saveProgress() } + player.stop() + } + .animation(.easeOut(duration: 0.2), value: panel) + .animation(.easeOut(duration: 0.2), value: controlsVisible) + } + + private var controlsOverlay: some View { + VStack(spacing: 0) { + HStack(alignment: .top) { + titleBlock + Spacer(minLength: 24) + if request.isLive { + Button { + withAnimation { panel = .liveGuide } + scheduleHide(cancelOnly: true) + } label: { + Label("Zenders", systemImage: "list.bullet") + .font(TvFonts.caption) + .foregroundStyle(.white) + .padding(.horizontal, 18) + .padding(.vertical, 14) + .background(.ultraThinMaterial, in: Capsule()) + } + .buttonStyle(FocusableCardStyle(scale: 1.08)) + } else { + HStack(spacing: 18) { + Button { + player.refreshTracks() + withAnimation { panel = .tracks } + scheduleHide(cancelOnly: true) + } label: { + Label("Audio", systemImage: "waveform") + .font(TvFonts.caption) + .foregroundStyle(.white) + .padding(.horizontal, 18) + .padding(.vertical, 14) + .background(.ultraThinMaterial, in: Capsule()) + } + .buttonStyle(FocusableCardStyle(scale: 1.08)) + .focused($focusedControl, equals: .audio) + + Button { + withAnimation { panel = .subtitles } + scheduleHide(cancelOnly: true) + Task { await loadSubtitles() } + } label: { + Label("Ondertitel", systemImage: "captions.bubble.fill") + .font(TvFonts.caption) + .foregroundStyle(.white) + .padding(.horizontal, 18) + .padding(.vertical, 14) + .background(.ultraThinMaterial, in: Capsule()) + } + .buttonStyle(FocusableCardStyle(scale: 1.08)) + .focused($focusedControl, equals: .captions) + } + .focusSection() + } + } + .padding(.horizontal, 48) + .padding(.top, 36) + .focusSection() + + Spacer() + + VStack(spacing: 20) { + if !request.isLive { + ProgressBar(fraction: CGFloat(player.position), height: 8) + HStack { + Text(player.timeText) + .font(TvFonts.caption) + .foregroundStyle(.white.opacity(0.85)) + .monospacedDigit() + Spacer() + Text(player.lengthText) + .font(TvFonts.caption) + .foregroundStyle(.white.opacity(0.85)) + .monospacedDigit() + } + } + + HStack(spacing: 48) { + if !request.isLive { + Button { + player.seek(byMs: -10_000) + showControls() + } label: { + Image(systemName: "gobackward.10") + .font(.system(size: 36)) + .foregroundStyle(.white) + } + .buttonStyle(FocusableCardStyle()) + .focused($focusedControl, equals: .rewind) + } + + Button { + player.togglePlay() + showControls() + } label: { + Image(systemName: player.isPlaying ? "pause.circle.fill" : "play.circle.fill") + .font(.system(size: 72)) + .foregroundStyle(.white) + .shadow(radius: 12) + } + .buttonStyle(FocusableCardStyle()) + .focused($focusedControl, equals: .play) + + if !request.isLive { + Button { + player.seek(byMs: 30_000) + showControls() + } label: { + Image(systemName: "goforward.30") + .font(.system(size: 36)) + .foregroundStyle(.white) + } + .buttonStyle(FocusableCardStyle()) + .focused($focusedControl, equals: .forward) + } + } + .focusSection() + } + .padding(40) + .background( + LinearGradient(colors: [.clear, .black.opacity(0.85)], startPoint: .top, endPoint: .bottom) + ) + } + } + + @ViewBuilder + private var titleBlock: some View { + VStack(alignment: .leading, spacing: 10) { + if request.isLive { + HStack(spacing: 16) { + if let logo = liveLogoUrl ?? request.logoUrl, !logo.isEmpty { + RemoteImage(url: logo, contentMode: .fit, opaqueBackground: false) + .frame(width: 72, height: 72) + .padding(8) + .background(Color.white.opacity(0.08), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + VStack(alignment: .leading, spacing: 6) { + Text("LIVE") + .font(.system(size: 16, weight: .heavy, design: .rounded)) + .tracking(1.2) + .foregroundStyle(TvColors.accent) + Text(liveTitle.isEmpty ? request.title : liveTitle) + .font(TvFonts.headline) + .foregroundStyle(.white) + .lineLimit(1) + Text(player.stateText) + .font(TvFonts.caption) + .foregroundStyle(.white.opacity(0.7)) + } + } + if let now = liveEpg?.now?.title { + Text("Nu · \(now)") + .font(TvFonts.body) + .foregroundStyle(.white.opacity(0.92)) + .lineLimit(1) + } + if let next = liveEpg?.next?.title { + Text("Straks · \(next)") + .font(TvFonts.caption) + .foregroundStyle(.white.opacity(0.65)) + .lineLimit(1) + } + Text("↑↓ zappen · ← zenderlijst · OK info") + .font(.system(size: 16, weight: .medium, design: .rounded)) + .foregroundStyle(.white.opacity(0.45)) + } else { + if let logo = request.logoUrl, !logo.isEmpty { + TitleLogo(url: logo, maxWidth: 360, maxHeight: 72) + } else { + Text(request.title) + .font(TvFonts.headline) + .foregroundStyle(.white) + .lineLimit(1) + } + Text(player.stateText) + .font(TvFonts.caption) + .foregroundStyle(.white.opacity(0.7)) + if player.durationMs() > 0 { + let remaining = max(0, player.durationMs() - player.currentPositionMs()) + Text(EndsAt.formatEndsAt(remainingMs: remaining)) + .font(TvFonts.caption) + .foregroundStyle(.white.opacity(0.75)) + } + } + } + } + + private var liveGuidePanel: some View { + HStack { + Spacer() + VStack(alignment: .leading, spacing: 0) { + Text("Zenders") + .font(TvFonts.headline) + .foregroundStyle(.white) + .padding(.bottom, 18) + + ScrollView { + LazyVStack(alignment: .leading, spacing: 10) { + ForEach(request.liveChannels) { ch in + Button { + Task { await zapTo(ch) } + } label: { + HStack(spacing: 14) { + RemoteImage(url: ch.logoUrl, contentMode: .fit, opaqueBackground: false) + .frame(width: 44, height: 44) + VStack(alignment: .leading, spacing: 4) { + Text(ch.name) + .font(TvFonts.body) + .foregroundStyle(.white) + .lineLimit(1) + if let now = ch.nowTitle, !now.isEmpty { + Text(now) + .font(TvFonts.caption) + .foregroundStyle(TvColors.textDim) + .lineLimit(1) + } + } + Spacer() + if ch.streamId == liveStreamId { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(TvColors.accent) + } + } + .padding(14) + .background( + (ch.streamId == liveStreamId ? TvColors.accent.opacity(0.2) : TvColors.surface.opacity(0.9)), + in: RoundedRectangle(cornerRadius: 12, style: .continuous) + ) + } + .buttonStyle(FocusableCardStyle(scale: 1.03)) + } + } + } + .focusSection() + + Button("Sluiten") { + withAnimation { panel = .none } + showControls() + } + .buttonStyle(TvSecondaryButtonStyle()) + .padding(.top, 20) + } + .padding(32) + .frame(width: 560) + .frame(maxHeight: .infinity, alignment: .top) + .background { Rectangle().fill(.ultraThinMaterial) } + .focusSection() + } + .background(Color.black.opacity(0.45).ignoresSafeArea()) + } + + private var tracksPanel: some View { + HStack { + Spacer() + VStack(alignment: .leading, spacing: 0) { + Text("Audio & ondertitels") + .font(TvFonts.headline) + .foregroundStyle(.white) + .padding(.bottom, 20) + + ScrollView { + VStack(alignment: .leading, spacing: 22) { + trackSection(title: "Audio", tracks: player.audioTracks, currentId: player.currentAudioId) { id in + player.selectAudio(id: id) + withAnimation { panel = .none } + showControls() + focusedControl = .play + } + trackSection(title: "Ingebedde ondertitels", tracks: player.subtitleTracks, currentId: player.currentSubtitleId) { id in + player.selectSubtitle(id: id) + withAnimation { panel = .none } + showControls() + focusedControl = .play + } + } + } + + Button("Sluiten") { + withAnimation { panel = .none } + showControls() + focusedControl = .audio + } + .buttonStyle(TvSecondaryButtonStyle()) + .padding(.top, 24) + } + .padding(36) + .frame(width: 620) + .frame(maxHeight: .infinity, alignment: .top) + .background { + Rectangle().fill(.ultraThinMaterial) + } + .focusSection() + } + .background(Color.black.opacity(0.45).ignoresSafeArea()) + } + + private func trackSection( + title: String, + tracks: [PlayerTrack], + currentId: Int32?, + onSelect: @escaping (Int32) -> Void + ) -> some View { + VStack(alignment: .leading, spacing: 12) { + Text(title) + .font(TvFonts.caption) + .foregroundStyle(TvColors.textDim) + if tracks.isEmpty { + Text("Geen tracks") + .font(TvFonts.body) + .foregroundStyle(TvColors.textDim) + } else { + ForEach(tracks) { track in + Button { + onSelect(track.id) + } label: { + HStack { + Text(track.name) + .font(TvFonts.body) + .foregroundStyle(.white) + Spacer() + if track.id == currentId { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(TvColors.accent) + } + } + .padding(.horizontal, 18) + .padding(.vertical, 14) + .background(TvColors.surface.opacity(0.9), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + .buttonStyle(FocusableCardStyle(scale: 1.04)) + } + } + } + } + + private var subtitlesPanel: some View { + HStack { + Spacer() + VStack(alignment: .leading, spacing: 0) { + Text("OpenSubtitles") + .font(TvFonts.headline) + .foregroundStyle(.white) + .padding(.bottom, 20) + + Group { + if subsLoading { + ProgressView("Zoeken…").tint(.white).padding(.top, 40) + } else if let subsError { + Text(subsError).foregroundStyle(TvColors.danger).padding(.top, 20) + } else if subtitleHits.isEmpty { + Text("Geen ondertitels gevonden") + .font(TvFonts.body) + .foregroundStyle(TvColors.textDim) + .padding(.top, 20) + } else { + ScrollView { + LazyVStack(alignment: .leading, spacing: 12) { + ForEach(subtitleHits) { hit in + Button { + Task { await applySubtitle(hit) } + } label: { + VStack(alignment: .leading, spacing: 6) { + Text(hit.release ?? hit.fileName ?? "Ondertitel") + .font(TvFonts.body) + .foregroundStyle(.white) + .lineLimit(2) + HStack(spacing: 12) { + Text((hit.language ?? "?").uppercased()) + if hit.hearingImpaired == true { Text("SDH") } + if let n = hit.downloadCount { Text("↓\(n)") } + Text((hit.source ?? "").uppercased()) + } + .font(TvFonts.caption) + .foregroundStyle(TvColors.textDim) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(TvColors.surface.opacity(0.9), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + .buttonStyle(FocusableCardStyle(scale: 1.03)) + } + } + } + .focusSection() + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + + Button("Sluiten") { + withAnimation { panel = .none } + showControls() + focusedControl = .captions + } + .buttonStyle(TvSecondaryButtonStyle()) + .padding(.top, 24) + } + .padding(36) + .frame(width: 680) + .frame(maxHeight: .infinity, alignment: .top) + .background { + Rectangle().fill(.ultraThinMaterial) + } + .focusSection() + } + .background(Color.black.opacity(0.45).ignoresSafeArea()) + } + + private func errorOverlay(_ message: String) -> some View { + VStack(spacing: 20) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 48)) + .foregroundStyle(TvColors.danger) + Text(message) + .font(TvFonts.body) + .foregroundStyle(.white) + .multilineTextAlignment(.center) + Button("Sluiten") { dismiss() } + .buttonStyle(TvPrimaryButtonStyle()) + } + .padding(40) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 24, style: .continuous)) + } + + private func showControls(cancelHide: Bool = false) { + controlsVisible = true + if cancelHide { + hideTask?.cancel() + } else { + scheduleHide() + } + } + + private func hideControls() { + guard panel == .none else { return } + controlsVisible = false + focusedControl = nil + } + + private func scheduleHide(cancelOnly: Bool = false) { + hideTask?.cancel() + if cancelOnly { return } + hideTask = Task { + try? await Task.sleep(nanoseconds: 5_000_000_000) + guard !Task.isCancelled, panel == .none else { return } + await MainActor.run { hideControls() } + } + } + + private func start() async { + liveStreamId = request.liveStreamId + liveTitle = request.title + liveLogoUrl = request.logoUrl + if request.isLive { + player.playLive(url: request.url, fallback: request.fallbackUrl) + if let id = request.liveStreamId { + await refreshLiveEpg(streamId: id) + } + } else { + player.play(url: request.url, startMs: request.startMs, subtitleFileURL: request.subtitleFileURL) + progressTask = Task { + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 12_000_000_000) + await saveProgress() + } + } + } + } + + private func handleLiveMove(_ direction: MoveCommandDirection) { + showControls() + switch direction { + case .up: + Task { await zap(delta: -1) } + case .down: + Task { await zap(delta: 1) } + case .left: + withAnimation { panel = .liveGuide } + scheduleHide(cancelOnly: true) + case .right: + break + @unknown default: + break + } + } + + private func zap(delta: Int) async { + let list = request.liveChannels + guard !list.isEmpty, !liveZapBusy else { return } + let current = liveStreamId ?? request.liveStreamId + guard let current, + let idx = list.firstIndex(where: { $0.streamId == current }) + else { return } + let nextIdx = (idx + delta + list.count) % list.count + await zapTo(list[nextIdx]) + } + + private func zapTo(_ ch: IptvChannel) async { + guard !liveZapBusy else { return } + liveZapBusy = true + defer { liveZapBusy = false } + do { + let info = try await app.api.iptvPlay(streamId: ch.streamId) + guard let url = URL(string: info.streamUrl) else { return } + let fallback = info.fallbackStreamUrl.flatMap(URL.init(string:)) + liveStreamId = ch.streamId + liveTitle = info.name + liveLogoUrl = info.logoUrl ?? ch.logoUrl + app.store.setLastIptvStream(ch.streamId) + player.playLive(url: url, fallback: fallback) + withAnimation { panel = .none } + showControls() + await refreshLiveEpg(streamId: ch.streamId) + } catch { + // keep current channel + } + } + + private func refreshLiveEpg(streamId: Int) async { + liveEpg = try? await app.api.iptvEpg(streamId: streamId) + } + + 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 + subtitleHits = [] + 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), !data.isEmpty else { + subsError = "Lege ondertitel" + return + } + let ext = (fetched.format?.isEmpty == false ? fetched.format! : "srt") + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("vmc-tv-sub-\(hit.fileId).\(ext)") + try data.write(to: url, options: .atomic) + player.addExternalSubtitle(url: url) + withAnimation { panel = .none } + showControls() + focusedControl = .captions + } 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 + var logoUrl: String? = nil + + @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 { + TVPlayerView(request: request) + } else if loading { + ProgressView("Stream voorbereiden…") + .tint(.white) + .foregroundStyle(.white) + .font(TvFonts.body) + } else if let error { + VStack(spacing: 16) { + Text(error).font(TvFonts.body).foregroundStyle(TvColors.danger).multilineTextAlignment(.center) + Button("Opnieuw") { Task { await prepare() } }.buttonStyle(TvPrimaryButtonStyle()) + } + } + } + .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") + } + var subtitleURL: URL? + if let sub = play.defaultSubtitle, + let data = Data(base64Encoded: sub.contentBase64), + !data.isEmpty { + let ext: String = { + let f = (sub.format ?? "").lowercased() + if f == "vtt" { return "vtt" } + if f == "ass" || f == "ssa" { return "ass" } + return "srt" + }() + let fileId = sub.fileId ?? 0 + let path = FileManager.default.temporaryDirectory + .appendingPathComponent("vmc-tv-auto-sub-\(fileId).\(ext)") + try data.write(to: path, options: .atomic) + subtitleURL = path + } + request = .vod( + url: url, title: title, mediaType: mediaType, mediaFileId: mediaFileId, + movieId: movieId, episodeId: episodeId, seriesId: seriesId, startMs: startMs, + subtitleFileURL: subtitleURL, logoUrl: logoUrl + ) + loading = false + } catch { + self.error = error.localizedDescription + loading = false + } + } +} diff --git a/apps/ios/VMCTV/Playback/VLCPlayerController.swift b/apps/ios/VMCTV/Playback/VLCPlayerController.swift new file mode 100644 index 0000000..7cd958d --- /dev/null +++ b/apps/ios/VMCTV/Playback/VLCPlayerController.swift @@ -0,0 +1,441 @@ +import Combine +import TVVLCKit +import SwiftUI +import UIKit + +final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDelegate, VLCMediaDelegate { + @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.delegate = self + 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") + media.addOption(":freetype-rel-fontsize=\(Self.subtitleRelFontSize)") + media.addOption(":freetype-bold=1") + mediaPlayer.media = media + media.parse() + 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.delegate = self + media.addOption(":network-caching=1200") + media.addOption(":http-reconnect") + media.addOption(":live-caching=1000") + media.addOption(":freetype-rel-fontsize=\(Self.subtitleRelFontSize)") + mediaPlayer.media = media + media.parse() + 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.delegate = self + fb.addOption(":network-caching=1200") + fb.addOption(":http-reconnect") + fb.addOption(":freetype-rel-fontsize=\(Self.subtitleRelFontSize)") + self.mediaPlayer.media = fb + fb.parse() + 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(byMs delta: Int64) { + seek(toMs: max(0, currentPositionMs() + delta)) + } + + 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: hoger getal = kleinere tekst. TV iets groter dan iPhone (32). + private static let subtitleRelFontSize = 22 + + 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() { + let meta = trackMetaById() + var textOrderHints: [String?] = [] + var audioOrderHints: [String?] = [] + for info in (mediaPlayer.media?.tracksInformation as? [Any]) ?? [] { + guard let dict = info as? [AnyHashable: Any] else { continue } + let type = ((dict[VLCMediaTracksInformationType] as? String) + ?? (dict["type"] as? String) + ?? "").lowercased() + let lang = (dict[VLCMediaTracksInformationLanguage] as? String) + ?? (dict["language"] as? String) + let desc = (dict[VLCMediaTracksInformationDescription] as? String) + ?? (dict["description"] as? String) + let hint = lang ?? desc + if type.contains("text") { + textOrderHints.append(hint) + } else if type.contains("audio") { + audioOrderHints.append(hint) + } + } + + var subs: [PlayerTrack] = [PlayerTrack(id: -1, name: "Uit")] + var textOrd = 0 + 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 rawName: String = { + if i < names.count, let n = names[i] as? String, !n.isEmpty { return n } + return "" + }() + let fromId = meta[idx] + let fromOrder = textOrd < textOrderHints.count ? textOrderHints[textOrd] : nil + textOrd += 1 + let label = Self.displayTrackLabel( + rawName: rawName, + fallbackIndex: subs.count, + kind: .subtitle, + languageHint: fromId?.language ?? fromOrder, + descriptionHint: fromId?.description + ) + subs.append(PlayerTrack(id: idx, name: label)) + } + } + subtitleTracks = subs + currentSubtitleId = mediaPlayer.currentVideoSubTitleIndex + + var audios: [PlayerTrack] = [] + var audioOrd = 0 + 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 rawName: String = { + if i < names.count, let n = names[i] as? String, !n.isEmpty { return n } + return "" + }() + let fromId = meta[idx] + let fromOrder = audioOrd < audioOrderHints.count ? audioOrderHints[audioOrd] : nil + audioOrd += 1 + let label = Self.displayTrackLabel( + rawName: rawName, + fallbackIndex: audios.count + 1, + kind: .audio, + languageHint: fromId?.language ?? fromOrder, + descriptionHint: fromId?.description + ) + audios.append(PlayerTrack(id: idx, name: label)) + } + } + audioTracks = audios + currentAudioId = mediaPlayer.currentAudioTrackIndex + } + + private struct TrackMeta { + var language: String? + var description: String? + } + + private func trackMetaById() -> [Int32: TrackMeta] { + guard let media = mediaPlayer.media else { return [:] } + var out: [Int32: TrackMeta] = [:] + for info in (media.tracksInformation as? [Any]) ?? [] { + guard let dict = info as? [AnyHashable: Any] else { continue } + let tid = (dict[VLCMediaTracksInformationId] as? NSNumber)?.int32Value + ?? (dict["id"] as? NSNumber)?.int32Value + guard let tid else { continue } + let lang = (dict[VLCMediaTracksInformationLanguage] as? String) + ?? (dict["language"] as? String) + let desc = (dict[VLCMediaTracksInformationDescription] as? String) + ?? (dict["description"] as? String) + out[tid] = TrackMeta(language: lang, description: desc) + } + return out + } + + private enum TrackKind { case subtitle, audio } + + private static func displayTrackLabel( + rawName: String, + fallbackIndex: Int, + kind: TrackKind, + languageHint: String?, + descriptionHint: String? + ) -> String { + let prettyLang = + prettyLanguage(languageHint) + ?? prettyLanguage(extractLanguageToken(from: descriptionHint)) + ?? prettyLanguage(extractLanguageToken(from: rawName)) + + if isGenericTrackLabel(rawName) + || rawName.uppercased().contains("TEXT/") + || rawName.uppercased().hasPrefix("HTTP") + { + if let prettyLang { + if let descriptionHint, + !descriptionHint.isEmpty, + !isGenericTrackLabel(descriptionHint), + !descriptionHint.localizedCaseInsensitiveContains(prettyLang) + { + return "\(prettyLang) · \(descriptionHint)" + } + return prettyLang + } + return kind == .subtitle ? "Ondertitel \(fallbackIndex)" : "Audio \(fallbackIndex)" + } + + if let prettyLang, !rawName.localizedCaseInsensitiveContains(prettyLang) { + return "\(prettyLang) · \(rawName)" + } + return rawName + } + + private static func isGenericTrackLabel(_ raw: String?) -> Bool { + let s = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if s.isEmpty { return true } + let lower = s.lowercased() + if lower == "und" || lower == "unknown" || lower == "null" || lower == "sub" || lower == "subtitle" { + return true + } + if lower.range(of: #"^track\s*\d+$"#, options: .regularExpression) != nil { return true } + if lower.range(of: #"^subtitle\s*\d+$"#, options: .regularExpression) != nil { return true } + if lower.range(of: #"^ondertitel\s*\d+$"#, options: .regularExpression) != nil { return true } + if lower.range(of: #"^audio\s*\d+$"#, options: .regularExpression) != nil { return true } + return false + } + + private static func extractLanguageToken(from raw: String?) -> String? { + guard let raw, !raw.isEmpty else { return nil } + let lower = raw.lowercased() + let known = [ + "nederlands", "dutch", "nl", "dut", "nld", + "engels", "english", "en", "eng", + "duits", "german", "de", "ger", "deu", + "frans", "french", "fr", "fre", "fra", + "spaans", "spanish", "es", "spa", + "italiaans", "italian", "it", "ita", + "portugees", "portuguese", "pt", "por", + "pools", "polish", "pl", "pol", + "russisch", "russian", "ru", "rus", + "japans", "japanese", "ja", "jpn", + "koreaans", "korean", "ko", "kor", + "chinees", "chinese", "zh", "chi", "zho", + ] + let tokens = lower.split(whereSeparator: { !$0.isLetter }).map(String.init) + for token in known where tokens.contains(token) || lower == token { + return token + } + if let match = lower.range(of: #"\b[a-z]{2,3}\b"#, options: .regularExpression) { + return String(lower[match]) + } + return nil + } + + private static func prettyLanguage(_ code: String?) -> String? { + let c = (code ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !c.isEmpty, c != "und", c != "null", c != "unknown" else { return nil } + switch c { + case "nl", "dut", "nld", "dutch", "nederlands": return "Nederlands" + case "en", "eng", "english", "engels": return "Engels" + case "de", "ger", "deu", "german", "duits": return "Duits" + case "fr", "fre", "fra", "french", "frans": return "Frans" + case "es", "spa", "spanish", "spaans": return "Spaans" + case "it", "ita", "italian", "italiaans": return "Italiaans" + case "pt", "por", "portuguese", "portugees": return "Portugees" + case "pl", "pol", "polish", "pools": return "Pools" + case "ru", "rus", "russian", "russisch": return "Russisch" + case "ja", "jpn", "japanese", "japans": return "Japans" + case "ko", "kor", "korean", "koreaans": return "Koreaans" + case "zh", "chi", "zho", "cmn", "chinese", "chinees": return "Chinees" + case "ar", "ara", "arabic", "arabisch": return "Arabisch" + case "tr", "tur", "turkish", "turks": return "Turks" + case "sv", "swe", "swedish", "zweeds": return "Zweeds" + case "no", "nor", "nb", "nn", "norwegian", "noors": return "Noors" + case "da", "dan", "danish", "deens": return "Deens" + case "fi", "fin", "finnish", "fins": return "Fins" + default: + if c.count <= 3 { + let name = Locale(identifier: "nl_NL").localizedString(forLanguageCode: c) + if let name, !name.isEmpty, name.lowercased() != c { return name.capitalized } + return c.uppercased() + } + return c.capitalized + } + } + + 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 + } + } + } + + func mediaDidFinishParsing(_ aMedia: VLCMedia) { + DispatchQueue.main.async { [weak self] in + self?.refreshTracks() + } + } + + private func startTicker() { + tick?.cancel() + tick = Timer.publish(every: 0.4, on: .main, in: .common) + .autoconnect() + .sink { [weak self] _ in + self?.refreshTime() + } + } + + private func refreshTime() { + let t = mediaPlayer.time.intValue + let l = mediaPlayer.media?.length.intValue ?? 0 + if l > 0 { lengthMs = l } + position = mediaPlayer.position + timeText = Self.format(ms: t) + lengthText = Self.format(ms: lengthMs) + isPlaying = mediaPlayer.isPlaying + } + + private static func format(ms: Int32) -> String { + let total = max(Int(ms) / 1000, 0) + let h = total / 3600 + let m = (total % 3600) / 60 + let s = total % 60 + if h > 0 { return String(format: "%d:%02d:%02d", h, m, s) } + return String(format: "%d:%02d", m, s) + } +} + +struct VLCVideoView: UIViewRepresentable { + let controller: VLCPlayerController + + func makeUIView(context: Context) -> UIView { + let view = UIView() + view.backgroundColor = .black + controller.attach(to: view) + return view + } + + func updateUIView(_ uiView: UIView, context: Context) { + controller.attach(to: uiView) + } +} diff --git a/apps/ios/VMCTV/README.md b/apps/ios/VMCTV/README.md new file mode 100644 index 0000000..f14cd4a --- /dev/null +++ b/apps/ios/VMCTV/README.md @@ -0,0 +1,35 @@ +# VMC Apple TV (VMCTV) + +Premium SwiftUI-client voor **Vonas Media Cluster** op Apple TV (tvOS 17+). + +## Openen + +```bash +cd ~/Code/mediacluster/apps/ios +bash scripts/fetch-tvvlckit.sh # ~180MB, eenmalig +bash scripts/link-tvvlckit.sh # → Vendor/TVVLCKit.xcframework +open VMCTV.xcodeproj +``` + +1. Target **VMCTV** +2. Signing Team: **X25NYX3K49** +3. Bundle ID: `nl.vonas.mediacluster.tvos` +4. Run op Apple TV Simulator of device + +## Playback + +Zelfde stack als iPhone: **TVVLCKit** (VLC). Speelt MKV/MP4/TS enz. via node-stream — **niet** AVPlayer. + +`Vendor/TVVLCKit.xcframework` staat niet in git (te groot). + +## Functies + +- Koppelen (grote code + QR) of wachtwoord +- Profielen + PIN +- Top-nav: Home, Watchlist, Films, Series, Live TV, Zoeken, Instellingen +- Hero/shelves met focus-effecten +- VLC-speler: remote controls, tracks, OpenSubtitles, ends-at + +## Platform + +`platform: "apple_tv"` diff --git a/apps/ios/VMCTV/Theme/ProfileAvatars.swift b/apps/ios/VMCTV/Theme/ProfileAvatars.swift new file mode 100644 index 0000000..546297a --- /dev/null +++ b/apps/ios/VMCTV/Theme/ProfileAvatars.swift @@ -0,0 +1,20 @@ +import Foundation + +enum ProfileAvatars { + static let all: [(id: String, emoji: String, label: String)] = [ + ("fox", "🦊", "Vos"), + ("panda", "🐼", "Panda"), + ("robot", "🤖", "Robot"), + ("alien", "👽", "Alien"), + ("ninja", "🥷", "Ninja"), + ("pirate", "🏴‍☠️", "Piraat"), + ("wizard", "🧙", "Tovenaar"), + ("dino", "🦕", "Dino"), + ("cat", "🐱", "Kat"), + ("rocket", "🚀", "Raket"), + ] + + static func emoji(for id: String?) -> String { + all.first { $0.id == (id ?? "fox") }?.emoji ?? "🙂" + } +} diff --git a/apps/ios/VMCTV/Theme/TvTheme.swift b/apps/ios/VMCTV/Theme/TvTheme.swift new file mode 100644 index 0000000..9d26313 --- /dev/null +++ b/apps/ios/VMCTV/Theme/TvTheme.swift @@ -0,0 +1,127 @@ +import SwiftUI + +enum TvColors { + static let bg = Color(red: 0.02, green: 0.02, blue: 0.025) + static let bgElevated = Color(red: 0.06, green: 0.06, blue: 0.07) + static let surface = Color(red: 0.11, green: 0.11, blue: 0.125) + static let surfaceSoft = Color(red: 0.16, green: 0.16, blue: 0.18) + /// Netflix-red #E50914 + 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.28) + static let text = Color(red: 0.97, green: 0.97, blue: 0.98) + static let textDim = Color(red: 0.62, green: 0.62, blue: 0.68) + 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) +} + +enum TvFonts { + static let brand = Font.system(size: 72, weight: .black, design: .rounded) + static let hero = Font.system(size: 56, weight: .black, design: .rounded) + static let title = Font.system(size: 42, weight: .bold, design: .rounded) + static let headline = Font.system(size: 28, weight: .semibold, design: .rounded) + static let body = Font.system(size: 24, weight: .regular, design: .rounded) + static let caption = Font.system(size: 20, weight: .medium, design: .rounded) + static let nav = Font.system(size: 22, weight: .semibold, design: .rounded) + static let code = Font.system(size: 64, weight: .bold, design: .monospaced) +} + +struct FocusableCardStyle: ButtonStyle { + var scale: CGFloat = 1.08 + + func makeBody(configuration: Configuration) -> some View { + FocusableCardLabel(configuration: configuration, scale: scale) + } +} + +private struct FocusableCardLabel: View { + let configuration: ButtonStyleConfiguration + let scale: CGFloat + @Environment(\.isFocused) private var isFocused + + var body: some View { + configuration.label + .scaleEffect(isFocused ? scale : (configuration.isPressed ? 0.98 : 1.0)) + .shadow(color: isFocused ? TvColors.accent.opacity(0.55) : .clear, radius: isFocused ? 28 : 0, y: 10) + .animation(.easeOut(duration: 0.18), value: isFocused) + .animation(.easeOut(duration: 0.12), value: configuration.isPressed) + } +} + +/// Top-nav: duidelijke focus-ring, aparte “actieve tab”-styling. +struct NavTabButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + NavTabButtonLabel(configuration: configuration) + } +} + +private struct NavTabButtonLabel: View { + let configuration: ButtonStyleConfiguration + @Environment(\.isFocused) private var isFocused + + var body: some View { + configuration.label + .scaleEffect(isFocused ? 1.06 : (configuration.isPressed ? 0.97 : 1.0)) + .shadow(color: isFocused ? Color.white.opacity(0.35) : .clear, radius: isFocused ? 16 : 0, y: 0) + .animation(.easeOut(duration: 0.15), value: isFocused) + .animation(.easeOut(duration: 0.1), value: configuration.isPressed) + } +} + +struct TvPrimaryButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + TvPrimaryButtonLabel(configuration: configuration) + } +} + +private struct TvPrimaryButtonLabel: View { + let configuration: ButtonStyleConfiguration + @Environment(\.isFocused) private var isFocused + + var body: some View { + configuration.label + .font(TvFonts.headline) + .foregroundStyle(.white) + .padding(.horizontal, 36) + .padding(.vertical, 18) + .background( + LinearGradient( + colors: [TvColors.accent, TvColors.accent.opacity(0.82)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + in: Capsule() + ) + .scaleEffect(isFocused ? 1.06 : (configuration.isPressed ? 0.97 : 1)) + .shadow(color: TvColors.accent.opacity(isFocused ? 0.55 : 0.28), radius: isFocused ? 22 : 10, y: 6) + .animation(.easeOut(duration: 0.18), value: isFocused) + } +} + +struct TvSecondaryButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + TvSecondaryButtonLabel(configuration: configuration) + } +} + +private struct TvSecondaryButtonLabel: View { + let configuration: ButtonStyleConfiguration + @Environment(\.isFocused) private var isFocused + + var body: some View { + configuration.label + .font(TvFonts.headline) + .foregroundStyle(TvColors.text) + .padding(.horizontal, 32) + .padding(.vertical, 16) + .background( + .ultraThinMaterial, + in: Capsule() + ) + .overlay { + Capsule() + .strokeBorder(isFocused ? TvColors.accent.opacity(0.7) : Color.white.opacity(0.12), lineWidth: isFocused ? 2 : 1) + } + .scaleEffect(isFocused ? 1.05 : 1) + .animation(.easeOut(duration: 0.18), value: isFocused) + } +} diff --git a/apps/ios/VMCTV/UI/AuthViews.swift b/apps/ios/VMCTV/UI/AuthViews.swift new file mode 100644 index 0000000..e953d3b --- /dev/null +++ b/apps/ios/VMCTV/UI/AuthViews.swift @@ -0,0 +1,347 @@ +import SwiftUI +import CoreImage +import CoreImage.CIFilterBuiltins + +enum ServerUrl { + static func normalize(_ raw: String, fallback: String = SessionStore.defaultApiBase) -> String { + var s = raw.trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "/")) + if s.isEmpty { return fallback.trimmingCharacters(in: CharacterSet(charactersIn: "/")) } + if !s.lowercased().hasPrefix("http://") && !s.lowercased().hasPrefix("https://") { + s = "https://\(s)" + } + return s.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + } +} + +private struct AuthBackground: View { + var body: some View { + LinearGradient( + colors: [ + Color(red: 0.12, green: 0.03, blue: 0.05), + TvColors.bg, + Color(red: 0.03, green: 0.04, blue: 0.1), + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .ignoresSafeArea() + } +} + +func qrImage(from string: String) -> Image? { + let context = CIContext() + let filter = CIFilter.qrCodeGenerator() + filter.message = Data(string.utf8) + filter.correctionLevel = "M" + guard let output = filter.outputImage else { return nil } + let scaled = output.transformed(by: CGAffineTransform(scaleX: 12, y: 12)) + guard let cg = context.createCGImage(scaled, from: scaled.extent) else { return nil } + return Image(decorative: cg, scale: 1) +} + +struct ServerChoiceView: View { + @EnvironmentObject private var app: AppModel + @State private var customMode = false + @State private var urlInput = "" + @State private var error: String? + @State private var busy = false + + var body: some View { + ZStack { + AuthBackground() + VStack(spacing: 28) { + Text("VMC") + .font(TvFonts.brand) + .foregroundStyle(TvColors.accent) + .shadow(color: TvColors.accent.opacity(0.45), radius: 28) + + Text("Verbind met je media") + .font(TvFonts.title) + .foregroundStyle(TvColors.text) + + Text("Vonas Media Cluster of je eigen server") + .font(TvFonts.body) + .foregroundStyle(TvColors.textDim) + + if !customMode { + Button("Doorgaan met VMC") { useVmc() } + .buttonStyle(TvPrimaryButtonStyle()) + .disabled(busy) + Button("Eigen server") { + customMode = true + error = nil + if app.store.apiBase != SessionStore.defaultApiBase { + urlInput = app.store.apiBase + } + } + .buttonStyle(TvSecondaryButtonStyle()) + } else { + TextField("https://media.voorbeeld.nl", text: $urlInput) + .font(TvFonts.body) + .foregroundStyle(TvColors.text) + .padding(20) + .background(TvColors.surface.opacity(0.9), in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + .frame(maxWidth: 720) + + Button(busy ? "Verbinden…" : "Doorgaan") { useCustom() } + .buttonStyle(TvPrimaryButtonStyle()) + .disabled(busy) + Button("Terug") { + customMode = false + error = nil + } + .buttonStyle(TvSecondaryButtonStyle()) + } + + if let error { + Text(error).font(TvFonts.body).foregroundStyle(TvColors.danger) + } + } + .padding(48) + .frame(maxWidth: 900) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 28, style: .continuous)) + } + } + + private func useVmc() { + busy = true + error = nil + app.store.setApiBase(SessionStore.defaultApiBase) + Task { + await app.api.configure(baseUrl: app.store.apiBase, token: nil) + busy = false + app.phase = .auth + } + } + + private func useCustom() { + guard !urlInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + error = "Voer een server-URL in" + return + } + busy = true + error = nil + app.store.setApiBase(ServerUrl.normalize(urlInput)) + Task { + await app.api.configure(baseUrl: app.store.apiBase, token: nil) + busy = false + app.phase = .auth + } + } +} + +struct AuthChoiceView: View { + @EnvironmentObject private var app: AppModel + + var body: some View { + ZStack { + AuthBackground() + VStack(spacing: 28) { + Text("VMC") + .font(TvFonts.brand) + .foregroundStyle(TvColors.accent) + Text("Aanmelden") + .font(TvFonts.title) + .foregroundStyle(TvColors.text) + Text("Koppel dit Apple TV-apparaat of log in met wachtwoord") + .font(TvFonts.body) + .foregroundStyle(TvColors.textDim) + .multilineTextAlignment(.center) + .frame(maxWidth: 700) + + Button("Koppelen met code") { app.phase = .link } + .buttonStyle(TvPrimaryButtonStyle()) + Button("Wachtwoord") { app.phase = .password } + .buttonStyle(TvSecondaryButtonStyle()) + Button("Andere server") { app.phase = .server } + .buttonStyle(TvSecondaryButtonStyle()) + } + .padding(48) + .frame(maxWidth: 900) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 28, style: .continuous)) + } + } +} + +struct LinkView: View { + @EnvironmentObject private var app: AppModel + @State private var code: String? + @State private var linkUrl = "" + @State private var qrPayload = "" + @State private var status = "Verbinden…" + @State private var error: String? + + var body: some View { + ZStack { + AuthBackground() + HStack(spacing: 56) { + VStack(spacing: 24) { + Text("Koppel dit apparaat") + .font(TvFonts.title) + .foregroundStyle(TvColors.text) + + Text("Open je Vonas-account, scan de QR of voer de code in.") + .font(TvFonts.body) + .foregroundStyle(TvColors.textDim) + .multilineTextAlignment(.center) + .frame(maxWidth: 520) + + if let code { + Text(code) + .font(TvFonts.code) + .foregroundStyle(TvColors.text) + .padding(.vertical, 20) + .padding(.horizontal, 36) + .background(TvColors.surface.opacity(0.95), in: RoundedRectangle(cornerRadius: 22, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 22, style: .continuous) + .stroke(TvColors.accent.opacity(0.45), lineWidth: 2) + ) + .shadow(color: TvColors.accent.opacity(0.25), radius: 24) + } else if error == nil { + ProgressView().tint(.white).scaleEffect(1.4) + } + + Text(status) + .font(TvFonts.caption) + .foregroundStyle(TvColors.textDim) + + if let error { + Text(error) + .font(TvFonts.body) + .foregroundStyle(TvColors.danger) + Button("Opnieuw") { Task { await startLink() } } + .buttonStyle(TvPrimaryButtonStyle()) + } + + HStack(spacing: 20) { + Button("Andere server") { app.phase = .server } + .buttonStyle(TvSecondaryButtonStyle()) + Button("Terug") { app.phase = .auth } + .buttonStyle(TvSecondaryButtonStyle()) + } + } + + if let payload = qrPayload.isEmpty ? (linkUrl.isEmpty ? nil : linkUrl) : qrPayload, + let image = qrImage(from: payload) { + image + .interpolation(.none) + .resizable() + .scaledToFit() + .frame(width: 320, height: 320) + .padding(24) + .background(Color.white, in: RoundedRectangle(cornerRadius: 20, style: .continuous)) + .shadow(color: .black.opacity(0.4), radius: 20) + } + } + .padding(48) + } + .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 ?? "" + qrPayload = created.qrPayload ?? created.linkUrl ?? created.code + 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" + } + } +} + +struct PasswordLoginView: View { + @EnvironmentObject private var app: AppModel + @State private var email = "" + @State private var password = "" + @State private var error: String? + @State private var busy = false + + var body: some View { + ZStack { + AuthBackground() + VStack(spacing: 22) { + Text("Inloggen met wachtwoord") + .font(TvFonts.title) + .foregroundStyle(TvColors.text) + + TextField("E-mail", text: $email) + .font(TvFonts.body) + .foregroundStyle(TvColors.text) + .padding(20) + .background(TvColors.surface.opacity(0.9), in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + .frame(maxWidth: 640) + + SecureField("Wachtwoord", text: $password) + .font(TvFonts.body) + .foregroundStyle(TvColors.text) + .padding(20) + .background(TvColors.surface.opacity(0.9), in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + .frame(maxWidth: 640) + + if let error { + Text(error).font(TvFonts.body).foregroundStyle(TvColors.danger) + } + + Button(busy ? "Bezig…" : "Inloggen") { Task { await submit() } } + .buttonStyle(TvPrimaryButtonStyle()) + .disabled(busy) + Button("Terug") { app.phase = .auth } + .buttonStyle(TvSecondaryButtonStyle()) + } + .padding(48) + .frame(maxWidth: 900) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 28, style: .continuous)) + } + } + + private func submit() async { + guard !email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, !password.isEmpty else { + error = "E-mail en wachtwoord verplicht" + return + } + busy = true + error = nil + await app.api.configure(baseUrl: app.store.apiBase, token: nil) + do { + let result = try await app.api.login( + email: email.trimmingCharacters(in: .whitespacesAndNewlines), + password: password, + deviceName: AppModel.deviceName + ) + await app.didLink(token: result.refreshToken) + } catch { + self.error = error.localizedDescription + } + busy = false + } +} diff --git a/apps/ios/VMCTV/UI/BrowseView.swift b/apps/ios/VMCTV/UI/BrowseView.swift new file mode 100644 index 0000000..b353be5 --- /dev/null +++ b/apps/ios/VMCTV/UI/BrowseView.swift @@ -0,0 +1,237 @@ +import SwiftUI + +struct BrowseView: View { + @EnvironmentObject private var app: AppModel + let kind: String + let title: String + + @State private var shelfPrefs: [ShelfPref] = [] + @State private var catalogId: String = "" + @State private var sort: SortMode = .title + @State private var pageTitle: String = "" + @State private var items: [MediaItem] = [] + @State private var total = 0 + @State private var listOffset = 0 + @State private var error: String? + @State private var loading = true + @State private var loadingMore = false + + private enum SortMode: String { + case title + case recent + } + + private var defaultCatalog: String { + kind == "MOVIE" ? "movies" : "series" + } + + private var catalogChips: [(label: String, id: String)] { + var list: [(String, String)] = [] + var seen = Set() + func append(_ label: String, _ id: String) { + guard !seen.contains(id) else { return } + seen.insert(id) + list.append((label, id)) + } + if kind == "MOVIE" { + append("Alles", "movies") + append("Recent", "recent-movies") + append("4K", "4k-movies") + } else { + append("Alles", "series") + append("Recent", "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 + append(pref.name, slug) + } + } + return list + } + + private var showSortTools: Bool { + switch catalogId { + case "recent-movies", "recent-series": return false + default: return true + } + } + + private let cols = [ + GridItem(.adaptive(minimum: 168, maximum: 200), spacing: 24) + ] + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + catalogBar + headerBar + gridContent + } + .background(TvColors.bg.ignoresSafeArea()) + .task { + if catalogId.isEmpty { catalogId = defaultCatalog } + await bootstrap() + } + .navigationDestination(for: MediaItem.self) { MediaRouter(item: $0) } + } + + private var catalogBar: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 14) { + ForEach(catalogChips, id: \.id) { chip in + let selected = catalogId == chip.id + Button { + guard catalogId != chip.id else { return } + catalogId = chip.id + if chip.id.contains("recent") || chip.id == "4k-movies" { + sort = .recent + } else { + sort = .title + } + Task { await reload(reset: true) } + } label: { + Text(chip.label) + .font(TvFonts.caption) + .foregroundStyle(selected ? .white : TvColors.text) + .padding(.horizontal, 22) + .padding(.vertical, 12) + .background(selected ? TvColors.accent : TvColors.surfaceSoft, in: Capsule()) + } + .buttonStyle(FocusableCardStyle()) + } + } + .padding(.horizontal, 48) + .padding(.vertical, 16) + } + .focusSection() + } + + private var headerBar: some View { + HStack(spacing: 16) { + Text(pageTitle.isEmpty ? title : pageTitle) + .font(TvFonts.title) + .foregroundStyle(TvColors.text) + Text(total > 0 ? "\(total)" : (loading ? "…" : "0")) + .font(TvFonts.body) + .foregroundStyle(TvColors.textDim) + Spacer() + if showSortTools { + HStack(spacing: 10) { + sortButton("A–Z", selected: sort == .title) { + sort = .title + Task { await reload(reset: true) } + } + sortButton("Recent", selected: sort == .recent) { + sort = .recent + Task { await reload(reset: true) } + } + } + } + } + .padding(.horizontal, 48) + .padding(.bottom, 12) + } + + private func sortButton(_ label: String, selected: Bool, action: @escaping () -> Void) -> some View { + Button(action: action) { + Text(label) + .font(TvFonts.caption) + .foregroundStyle(selected ? .black : TvColors.text) + .padding(.horizontal, 20) + .padding(.vertical, 10) + .background(selected ? Color.white : TvColors.surface, in: Capsule()) + } + .buttonStyle(FocusableCardStyle()) + } + + @ViewBuilder + private var gridContent: some View { + if loading && items.isEmpty { + ProgressView().tint(.white).frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error, items.isEmpty { + VStack(spacing: 16) { + Text(error).font(TvFonts.body).foregroundStyle(TvColors.danger) + Button("Opnieuw") { Task { await reload(reset: true) } }.buttonStyle(TvPrimaryButtonStyle()) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if !loading && items.isEmpty { + Text("Geen titels") + .font(TvFonts.title) + .foregroundStyle(TvColors.textDim) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + LazyVGrid(columns: cols, spacing: 28) { + ForEach(items) { item in + NavigationLink(value: item) { + PosterCard(item: item, width: 168, showTitle: true) + } + .buttonStyle(FocusableCardStyle()) + .onAppear { + if item.id == items.last?.id { + Task { await loadMore() } + } + } + } + } + .padding(.horizontal, 48) + .padding(.vertical, 28) + .padding(.bottom, 48) + if loadingMore { + ProgressView().tint(.white).padding(.bottom, 24) + } + } + .scrollClipDisabled() + .focusSection() + } + } + + private func bootstrap() async { + shelfPrefs = (try? await app.api.getShelfPrefs())?.sorted(by: { $0.sortOrder < $1.sortOrder }) ?? [] + await reload(reset: true) + } + + private func reload(reset: Bool) async { + if reset { + loading = true + error = nil + } + let id = catalogId.isEmpty ? defaultCatalog : catalogId + let sortParam: String? = showSortTools ? sort.rawValue : nil + do { + let page = try await app.api.browse(catalogId: id, skip: 0, take: 60, sort: sortParam) + pageTitle = page.title?.isEmpty == false ? page.title! : title + total = page.total ?? page.items.count + listOffset = page.offset ?? 0 + items = page.items + loading = false + } catch { + self.error = error.localizedDescription + loading = false + } + } + + private func loadMore() async { + guard !loadingMore, items.count + listOffset < total else { return } + loadingMore = true + let id = catalogId.isEmpty ? defaultCatalog : catalogId + let sortParam: String? = showSortTools ? sort.rawValue : nil + do { + let page = try await app.api.browse( + catalogId: id, + skip: listOffset + items.count, + take: 60, + sort: sortParam + ) + let existing = Set(items.map(\.id)) + items.append(contentsOf: page.items.filter { !existing.contains($0.id) }) + loadingMore = false + } catch { + loadingMore = false + } + } +} diff --git a/apps/ios/VMCTV/UI/Components.swift b/apps/ios/VMCTV/UI/Components.swift new file mode 100644 index 0000000..6c9853d --- /dev/null +++ b/apps/ios/VMCTV/UI/Components.swift @@ -0,0 +1,271 @@ +import SwiftUI + +enum RemoteImageRole { + /// Shelf/grid posters (~168pt breed) → TMDB w342 + case poster + /// Hero / detail backdrop → TMDB w1280 i.p.v. original + case backdrop + /// Title logo → w500 + case logo + /// Ongewijzigd + case raw +} + +struct RemoteImage: View { + let url: String? + var contentMode: ContentMode = .fill + var opaqueBackground: Bool = true + /// Bij `.fill`: voorkomt dat gezichten/logo’s bovenin worden weggeknipt. + var alignment: Alignment = .center + var role: RemoteImageRole = .poster + + var body: some View { + Group { + if let u = Self.tunedURL(url, role: role) { + 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 + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: alignment) + .clipped() + .background(opaqueBackground ? TvColors.surface : Color.clear) + } + + private var placeholder: some View { + Rectangle().fill(TvColors.surface) + } + + /// Zelfde look op TV, veel kleinere downloads (esp. home met veel posters + hero). + static func tunedURL(_ raw: String?, role: RemoteImageRole) -> URL? { + guard var s = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !s.isEmpty else { + return nil + } + if s.contains("image.tmdb.org/t/p/") { + switch role { + case .poster: + s = s + .replacingOccurrences(of: "/t/p/original/", with: "/t/p/w342/") + .replacingOccurrences(of: "/t/p/w780/", with: "/t/p/w342/") + .replacingOccurrences(of: "/t/p/w500/", with: "/t/p/w342/") + case .backdrop: + s = s + .replacingOccurrences(of: "/t/p/original/", with: "/t/p/w1280/") + .replacingOccurrences(of: "/t/p/w1920/", with: "/t/p/w1280/") + case .logo: + s = s + .replacingOccurrences(of: "/t/p/original/", with: "/t/p/w500/") + .replacingOccurrences(of: "/t/p/w780/", with: "/t/p/w500/") + case .raw: + break + } + } + return URL(string: s) + } +} + +struct TitleLogo: View { + let url: String? + var maxWidth: CGFloat = 420 + var maxHeight: CGFloat = 120 + + var body: some View { + RemoteImage(url: url, contentMode: .fit, opaqueBackground: false, role: .logo) + .frame(maxWidth: maxWidth, maxHeight: maxHeight, alignment: .leading) + .shadow(color: .black.opacity(0.55), radius: 16, y: 6) + } +} + +struct BrandLogo: View { + var size: CGFloat = 36 + + var body: some View { + HStack(spacing: 12) { + ZStack { + RoundedRectangle(cornerRadius: size * 0.28, style: .continuous) + .fill( + LinearGradient( + colors: [TvColors.accent, TvColors.accent.opacity(0.7)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: size, height: size) + .shadow(color: TvColors.accent.opacity(0.45), radius: 12, y: 3) + Text("V") + .font(.system(size: size * 0.55, weight: .black, design: .rounded)) + .foregroundStyle(.white) + } + VStack(alignment: .leading, spacing: 0) { + Text("VMC") + .font(.system(size: size * 0.55, weight: .black, design: .rounded)) + .foregroundStyle(TvColors.text) + Text("Media Cluster") + .font(.system(size: size * 0.28, weight: .medium, design: .rounded)) + .foregroundStyle(TvColors.textDim) + } + } + } +} + +struct PosterCard: View { + let item: MediaItem + var width: CGFloat = 180 + var showTitle: Bool = true + @Environment(\.isFocused) private var isFocused + + var body: some View { + VStack(alignment: .leading, spacing: showTitle ? 10 : 0) { + ZStack(alignment: .bottom) { + RemoteImage(url: item.posterUrl, alignment: .top, role: .poster) + .frame(width: width, height: width * 1.5) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + + if let pos = item.positionMs, let dur = item.durationMs, dur > 0, pos > 0 { + ProgressBar(fraction: min(CGFloat(pos) / CGFloat(dur), 1), height: 5) + .padding(8) + } + } + .overlay(alignment: .topTrailing) { + if let badge = item.badge, !badge.isEmpty { + Text(badge) + .font(.system(size: 16, weight: .bold)) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(TvColors.accent, in: Capsule()) + .foregroundStyle(.white) + .padding(10) + } + } + .overlay { + if isFocused { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .strokeBorder(TvColors.accent, lineWidth: 3.5) + .shadow(color: TvColors.accent.opacity(0.7), radius: 18, y: 0) + .allowsHitTesting(false) + } + } + .shadow(color: isFocused ? TvColors.accent.opacity(0.45) : .black.opacity(0.35), radius: isFocused ? 24 : 8, y: isFocused ? 12 : 4) + .animation(.easeOut(duration: 0.2), value: isFocused) + + if showTitle { + Text(item.title) + .font(TvFonts.caption) + .foregroundStyle(isFocused ? TvColors.text : TvColors.textDim) + .lineLimit(2) + .frame(width: width, alignment: .leading) + } + } + .frame(width: width, alignment: .leading) + } +} + +struct ProgressBar: View { + let fraction: CGFloat + var height: CGFloat = 6 + + var body: some View { + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule().fill(Color.white.opacity(0.25)) + Capsule() + .fill(TvColors.accent) + .frame(width: max(0, geo.size.width * max(0, min(fraction, 1)))) + } + } + .frame(height: height) + } +} + +enum EndsAt { + static func formatEndsAt(remainingMs: Int64) -> String { + guard remainingMs >= 30_000 else { return "" } + let end = Date().addingTimeInterval(Double(remainingMs) / 1000.0) + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "nl_NL") + formatter.dateFormat = "HH:mm" + return "Eindigt om \(formatter.string(from: end))" + } + + /// Resterende speelduur op basis van runtime (minuten) + voortgang, zoals Android TV. + static func remainingMs(runtimeMin: Int?, positionMs: Int64?, completed: Bool?) -> Int64? { + guard let runtimeMin, runtimeMin > 0, completed != true else { return nil } + let total = Int64(runtimeMin) * 60_000 + let remaining = max(0, total - max(0, positionMs ?? 0)) + return remaining >= 30_000 ? remaining : nil + } + + static func formatTime(_ ms: Int64) -> String { + let total = max(0, Int(ms / 1000)) + 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 MediaRouter: View { + let item: MediaItem + + var body: some View { + switch item.type.lowercased() { + case "movie": + MovieDetailView(movieId: item.id) + case "series", "show": + SeriesDetailView(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, + logoUrl: item.logoUrl + ) + } else { + Text("Niet beschikbaar") + .font(TvFonts.title) + .foregroundStyle(TvColors.textDim) + } + default: + MovieDetailView(movieId: item.id) + } + } +} + +struct MetaChip: View { + let text: String + var body: some View { + Text(text) + .font(TvFonts.caption) + .foregroundStyle(TvColors.textDim) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(TvColors.surfaceSoft, in: Capsule()) + } +} diff --git a/apps/ios/VMCTV/UI/DetailViews.swift b/apps/ios/VMCTV/UI/DetailViews.swift new file mode 100644 index 0000000..47125a4 --- /dev/null +++ b/apps/ios/VMCTV/UI/DetailViews.swift @@ -0,0 +1,502 @@ +import SwiftUI + +struct MovieDetailView: 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).font(TvFonts.body).foregroundStyle(TvColors.danger).padding() + } else if let movie { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + DetailHero( + backdrop: movie.backdropUrl ?? movie.posterUrl, + logo: movie.logoUrl, + title: movie.title + ) + + VStack(alignment: .leading, spacing: 22) { + metaRow(year: movie.year, runtime: movie.runtime, rating: movie.rating, genres: movie.genres) + + HStack(spacing: 20) { + if let mediaFileId = movie.preferredMediaFileId { + Button { playId = mediaFileId } label: { + Label("Afspelen", systemImage: "play.fill") + } + .buttonStyle(TvPrimaryButtonStyle()) + } + + Button { + 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 + ) + } + } label: { + Label( + app.favorites.contains(type: "movie", id: movie.id) + ? "In watchlist" + : "Watchlist", + systemImage: app.favorites.contains(type: "movie", id: movie.id) + ? "bookmark.fill" + : "bookmark" + ) + } + .buttonStyle(TvSecondaryButtonStyle()) + + Button { + Task { + try? await app.api.markWatched(movieId: movie.id) + } + } label: { + Label("Gezien", systemImage: "checkmark.circle") + } + .buttonStyle(TvSecondaryButtonStyle()) + } + .focusSection() + + if let overview = movie.overview, !overview.isEmpty { + Text(overview) + .font(TvFonts.body) + .foregroundStyle(TvColors.textDim) + .frame(maxWidth: 1100, alignment: .leading) + } + + peopleSection(cast: movie.cast, director: movie.director) + + if let files = movie.mediaFiles, files.count > 1 { + Text("Versies") + .font(TvFonts.headline) + .foregroundStyle(TvColors.text) + ForEach(files) { file in + Button { playId = file.id } label: { + HStack(spacing: 16) { + Text(file.label ?? file.resolution ?? "Versie") + .font(TvFonts.body) + .foregroundStyle(TvColors.text) + if let res = file.resolution { + MetaChip(text: res) + } + if file.nodeOnline == false { + Text("offline").foregroundStyle(TvColors.danger).font(TvFonts.caption) + } + Spacer() + Image(systemName: "play.circle.fill") + .font(.system(size: 32)) + .foregroundStyle(TvColors.accent) + } + .padding(20) + .background(TvColors.surface, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + } + .buttonStyle(FocusableCardStyle()) + } + } + } + .padding(.horizontal, 56) + .padding(.top, 12) + .padding(.bottom, 48) + } + } + .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, + logoUrl: movie.logoUrl + ) + .environmentObject(app) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(TvColors.bg.ignoresSafeArea()) + .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 SeriesDetailView: 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).font(TvFonts.body).foregroundStyle(TvColors.danger).padding() + } else if let series { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + DetailHero( + backdrop: series.backdropUrl ?? series.posterUrl, + logo: series.logoUrl, + title: series.title + ) + + VStack(alignment: .leading, spacing: 22) { + metaRow(year: series.year, runtime: nil, rating: series.rating, genres: series.genres) + + Button { + 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 watchlist" + : "Watchlist", + systemImage: app.favorites.contains(type: "series", id: series.id) + ? "bookmark.fill" + : "bookmark" + ) + } + .buttonStyle(TvSecondaryButtonStyle()) + + if let overview = series.overview, !overview.isEmpty { + Text(overview) + .font(TvFonts.body) + .foregroundStyle(TvColors.textDim) + .frame(maxWidth: 1100, alignment: .leading) + } + + peopleSection(cast: series.cast, director: series.director) + + if let seasons = series.seasons, !seasons.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(Array(seasons.enumerated()), id: \.offset) { idx, season in + Button { + withAnimation(.easeOut(duration: 0.2)) { seasonIndex = idx } + } label: { + Text("Seizoen \(season.seasonNumber)") + .font(TvFonts.caption) + .foregroundStyle(seasonIndex == idx ? .white : TvColors.textDim) + .padding(.horizontal, 22) + .padding(.vertical, 12) + .background(seasonIndex == idx ? TvColors.accent : TvColors.surface, in: Capsule()) + } + .buttonStyle(FocusableCardStyle()) + } + } + } + .focusSection() + + let episodes = seasons[safe: seasonIndex]?.episodes ?? [] + LazyVStack(spacing: 14) { + ForEach(episodes) { ep in + episodeRow(ep, seriesTitle: series.title) + } + } + .focusSection() + } + } + .padding(.horizontal, 56) + .padding(.top, 12) + .padding(.bottom, 48) + } + } + .fullScreenCover(item: $playTarget) { target in + PlayLauncher( + mediaFileId: target.mediaFileId, + title: target.title, + mediaType: "episode", + movieId: nil, + episodeId: target.episodeId, + seriesId: seriesId, + startMs: target.startMs, + logoUrl: target.logoUrl + ) + .environmentObject(app) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(TvColors.bg.ignoresSafeArea()) + .task { await load() } + } + + @ViewBuilder + private func episodeRow(_ ep: Episode, seriesTitle: String) -> some View { + EpisodeRowButton( + ep: ep, + seriesTitle: seriesTitle, + seriesBackdrop: series?.backdropUrl, + seriesLogo: series?.logoUrl + ) { play in + playTarget = play + } + } + + 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 + + private let height: CGFloat = 560 + + var body: some View { + ZStack(alignment: .bottomLeading) { + RemoteImage(url: backdrop, alignment: .top, role: .backdrop) + .frame(maxWidth: .infinity) + .frame(height: height) + .clipped() + + LinearGradient( + colors: [.clear, .black.opacity(0.15), TvColors.bg.opacity(0.9), TvColors.bg], + startPoint: .top, + endPoint: .bottom + ) + + VStack(alignment: .leading, spacing: 12) { + if let logo, !logo.isEmpty { + TitleLogo(url: logo, maxWidth: 480, maxHeight: 100) + } else { + Text(title) + .font(TvFonts.hero) + .foregroundStyle(.white) + .shadow(color: .black.opacity(0.5), radius: 10, y: 4) + .lineLimit(2) + } + } + .padding(.horizontal, 56) + .padding(.bottom, 28) + } + .frame(height: height) + } +} + +private struct EpisodeRowButton: View { + @EnvironmentObject private var app: AppModel + let ep: Episode + let seriesTitle: String + let seriesBackdrop: String? + let seriesLogo: String? + let onPlay: (EpisodePlay) -> Void + + private var title: String { + (ep.title?.isEmpty == false) ? ep.title! : "Aflevering \(ep.episodeNumber ?? 0)" + } + + private var playable: Bool { + ep.mediaFileId.map { !$0.isEmpty } ?? false + } + + var body: some View { + Button { + guard let mediaFileId = ep.mediaFileId, playable else { return } + onPlay( + EpisodePlay( + mediaFileId: mediaFileId, + episodeId: ep.id, + title: "\(seriesTitle) · \(title)", + startMs: ep.resumeMs, + logoUrl: seriesLogo + ) + ) + } label: { + EpisodeRowLabel( + ep: ep, + title: title, + playable: playable, + seriesBackdrop: seriesBackdrop + ) + } + .buttonStyle(FocusableCardStyle(scale: 1.03)) + .disabled(!playable) + .contextMenu { + Button("Markeer als gezien") { + Task { try? await app.api.markWatched(episodeId: ep.id) } + } + } + } +} + +private struct EpisodeRowLabel: View { + let ep: Episode + let title: String + let playable: Bool + let seriesBackdrop: String? + @Environment(\.isFocused) private var isFocused + + private var endsAtText: String? { + guard isFocused, + let remaining = EndsAt.remainingMs( + runtimeMin: ep.runtime, + positionMs: ep.positionMs, + completed: ep.completed + ) + else { return nil } + let text = EndsAt.formatEndsAt(remainingMs: remaining) + return text.isEmpty ? nil : text + } + + var body: some View { + HStack(spacing: 20) { + ZStack(alignment: .bottom) { + RemoteImage(url: ep.stillUrl ?? seriesBackdrop, alignment: .top, role: .backdrop) + .frame(width: 220, height: 124) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .opacity(ep.completed == true ? 0.55 : 1) + + if ep.progressFraction > 0.02 && ep.completed != true { + ProgressBar(fraction: ep.progressFraction, height: 5) + .padding(8) + } + } + + VStack(alignment: .leading, spacing: 8) { + Text("\(ep.episodeNumber ?? 0). \(title)") + .font(TvFonts.headline) + .foregroundStyle(playable ? TvColors.text : TvColors.textDim) + .lineLimit(2) + + HStack(spacing: 14) { + if let runtime = ep.runtime { + Text("\(runtime) min") + .font(TvFonts.caption) + .foregroundStyle(TvColors.textDim) + } + if let endsAtText { + Text(endsAtText) + .font(TvFonts.caption) + .foregroundStyle(Color(red: 1.0, green: 0.84, blue: 0.2)) + } + } + .animation(.easeOut(duration: 0.15), value: isFocused) + + if let overview = ep.overview, !overview.isEmpty { + Text(overview) + .font(TvFonts.caption) + .foregroundStyle(TvColors.textDim) + .lineLimit(2) + } + } + Spacer() + if playable { + Image(systemName: "play.circle.fill") + .font(.system(size: 40)) + .foregroundStyle(TvColors.accent) + } + } + .padding(16) + .background( + (isFocused ? TvColors.surface : TvColors.bgElevated), + in: RoundedRectangle(cornerRadius: 16, style: .continuous) + ) + .overlay { + if isFocused { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .strokeBorder(TvColors.accent.opacity(0.7), lineWidth: 2) + } + } + } +} + +private struct EpisodePlay: Identifiable { + let id = UUID() + let mediaFileId: String + let episodeId: String + let title: String + let startMs: Int64 + let logoUrl: String? +} + +@ViewBuilder +private func metaRow(year: Int?, runtime: Int?, rating: String?, genres: [String]?) -> some View { + HStack(spacing: 12) { + if let year { MetaChip(text: "\(year)") } + if let runtime { MetaChip(text: "\(runtime) min") } + if let rating { MetaChip(text: "★ \(rating)") } + ForEach((genres ?? []).prefix(5), id: \.self) { MetaChip(text: $0) } + } +} + +@ViewBuilder +private func peopleSection(cast: [String]?, director: [String]?) -> some View { + if let director, !director.isEmpty { + VStack(alignment: .leading, spacing: 6) { + Text("Regie").font(TvFonts.caption).foregroundStyle(TvColors.textDim) + Text(director.joined(separator: ", ")) + .font(TvFonts.body) + .foregroundStyle(TvColors.text) + } + } + if let cast, !cast.isEmpty { + VStack(alignment: .leading, spacing: 6) { + Text("Cast").font(TvFonts.caption).foregroundStyle(TvColors.textDim) + Text(cast.prefix(10).joined(separator: ", ")) + .font(TvFonts.body) + .foregroundStyle(TvColors.text) + .frame(maxWidth: 1100, alignment: .leading) + } + } +} + +private struct IdentifiedString: Identifiable { let id: String } + +private extension Array { + subscript(safe index: Int) -> Element? { + indices.contains(index) ? self[index] : nil + } +} diff --git a/apps/ios/VMCTV/UI/HomeView.swift b/apps/ios/VMCTV/UI/HomeView.swift new file mode 100644 index 0000000..2f74d1c --- /dev/null +++ b/apps/ios/VMCTV/UI/HomeView.swift @@ -0,0 +1,265 @@ +import SwiftUI + +struct HomeView: View { + @EnvironmentObject private var app: AppModel + @State private var home: HomePayload? + @State private var error: String? + @State private var loading = true + @State private var playTarget: HomePlayTarget? + + private var heroItems: [MediaItem] { + guard let home else { return [] } + var seen = Set() + var out: [MediaItem] = [] + let candidates = (home.spotlight ?? []) + + home.continueWatching + + home.shelves.flatMap(\.items) + for item in candidates { + if seen.insert(item.id).inserted { + out.append(item) + } + if out.count >= 8 { break } + } + return out + } + + 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: 20) { + Text(error).font(TvFonts.body).foregroundStyle(TvColors.danger).multilineTextAlignment(.center) + Button("Opnieuw") { Task { await load() } }.buttonStyle(TvPrimaryButtonStyle()) + } + } else if let home { + ScrollView(.vertical, showsIndicators: false) { + // LazyVStack: alleen zichtbare planken laden posters (anders 8+ rijen tegelijk = traag). + LazyVStack(alignment: .leading, spacing: 28) { + if !heroItems.isEmpty { + HomeHeroCarousel(items: heroItems) { item in + playTarget = HomePlayTarget(item: item) + } + } + + if !home.continueWatching.isEmpty { + TvShelfSection( + title: "Verder kijken", + items: home.continueWatching, + showContinueActions: true, + onReload: { Task { await load() } } + ) + } + + ForEach(home.shelves) { shelf in + if !shelf.items.isEmpty { + TvShelfSection(title: shelf.title, items: shelf.items, onReload: { Task { await load() } }) + } + } + } + .padding(.bottom, 60) + } + .navigationDestination(for: MediaItem.self) { MediaRouter(item: $0) } + } + } + .background(TvColors.bg.ignoresSafeArea()) + .task { await load() } + .fullScreenCover(item: $playTarget) { target in + MediaRouter(item: target.item) + .environmentObject(app) + } + } + + 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 + } + } +} + +private struct HomePlayTarget: Identifiable { + var id: String { item.id } + let item: MediaItem +} + +struct HomeHeroCarousel: View { + let items: [MediaItem] + let onPlay: (MediaItem) -> Void + + @State private var index = 0 + @State private var kenBurns = false + private let rotate = Timer.publish(every: 7, on: .main, in: .common).autoconnect() + + private let heroHeight: CGFloat = 720 + + private var item: MediaItem { + items[index % max(items.count, 1)] + } + + var body: some View { + ZStack(alignment: .bottomLeading) { + GeometryReader { geo in + RemoteImage(url: item.backdropUrl ?? item.posterUrl, alignment: .top, role: .backdrop) + .frame(width: geo.size.width, height: heroHeight * (kenBurns ? 1.06 : 1.0), alignment: .top) + .frame(width: geo.size.width, height: heroHeight, alignment: .top) + .clipped() + .id(item.id) + .transition(.opacity) + .animation(.easeInOut(duration: 14), value: kenBurns) + } + .frame(height: heroHeight) + + LinearGradient( + colors: [ + Color.black.opacity(0.25), + .clear, + .clear, + TvColors.bg.opacity(0.55), + TvColors.bg + ], + startPoint: .top, + endPoint: .bottom + ) + + VStack(alignment: .leading, spacing: 18) { + Text(item.type.uppercased() == "SERIES" ? "SERIE" : "FILM") + .font(.system(size: 18, weight: .bold, design: .rounded)) + .tracking(2) + .foregroundStyle(TvColors.accent) + + if let logo = item.logoUrl, !logo.isEmpty { + TitleLogo(url: logo, maxWidth: 560, maxHeight: 120) + } else { + Text(item.title) + .font(TvFonts.hero) + .foregroundStyle(.white) + .shadow(color: .black.opacity(0.6), radius: 12, y: 4) + .lineLimit(2) + } + + if let year = item.year { + Text("\(year)") + .font(TvFonts.body) + .foregroundStyle(.white.opacity(0.8)) + } + + HStack(spacing: 20) { + Button { onPlay(item) } label: { + Label("Afspelen", systemImage: "play.fill") + } + .buttonStyle(TvPrimaryButtonStyle()) + + NavigationLink(value: item) { + Label("Meer info", systemImage: "info.circle") + } + .buttonStyle(TvSecondaryButtonStyle()) + + if items.count > 1 { + HStack(spacing: 8) { + ForEach(0.. 1 else { return } + withAnimation(.easeInOut(duration: 0.85)) { + index = (index + 1) % items.count + } + kenBurns = false + withAnimation(.easeInOut(duration: 14).repeatForever(autoreverses: true)) { + kenBurns = true + } + } + .onChange(of: items.map(\.id)) { _, _ in + index = 0 + } + } +} + +struct TvShelfSection: View { + @EnvironmentObject private var app: AppModel + let title: String + let items: [MediaItem] + var showContinueActions: Bool = false + var onReload: (() -> Void)? = nil + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text(title) + .font(TvFonts.headline) + .foregroundStyle(TvColors.text) + .padding(.horizontal, 48) + + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack(alignment: .center, spacing: 26) { + ForEach(items) { item in + NavigationLink(value: item) { + PosterCard(item: item, width: 168) + } + // Lichtere scale: voorkomt knippen in scroll + dubbele scale met PosterCard-border. + .buttonStyle(FocusableCardStyle(scale: 1.06)) + .contextMenu { + if showContinueActions || item.progressId != nil { + if let pid = item.progressId { + Button("Verwijderen uit verder kijken") { + Task { + try? await app.api.dismissContinue(progressId: pid) + onReload?() + } + } + } + Button("Markeer als gezien") { + Task { + let t = item.type.lowercased() + if t == "episode" { + try? await app.api.markWatched(episodeId: item.id) + } else { + try? await app.api.markWatched(movieId: item.id) + } + if let pid = item.progressId { + try? await app.api.dismissContinue(progressId: pid) + } + onReload?() + } + } + } + } + } + } + .padding(.horizontal, 48) + // Ruimte voor focus-scale zodat covers niet bovenin wegvallen. + .padding(.vertical, 36) + } + .scrollClipDisabled() + .focusSection() + } + } +} diff --git a/apps/ios/VMCTV/UI/LiveTvView.swift b/apps/ios/VMCTV/UI/LiveTvView.swift new file mode 100644 index 0000000..d54b49d --- /dev/null +++ b/apps/ios/VMCTV/UI/LiveTvView.swift @@ -0,0 +1,660 @@ +import SwiftUI + +/// Premium Live TV browse — Apple HIG live-viewing + cinema-grade EPG paneel. +struct LiveTvView: View { + @EnvironmentObject private var app: AppModel + + @State private var categories: [IptvCategory] = [] + @State private var channels: [IptvChannel] = [] + @State private var allChannelsCache: [IptvChannel] = [] + @State private var selectedCategory: String = "__all__" + @FocusState private var focusedStreamId: Int? + @State private var epg: IptvEpg? + @State private var epgLoading = false + @State private var searchText = "" + @State private var error: String? + @State private var loading = true + @State private var channelsLoading = false + @State private var playSession: LivePlaySession? + @State private var clock = Date() + + private static let lastCategoryKey = "vmc.tv.iptv.last_category" + private static let favoritesId = "__fav__" + private let clockTimer = Timer.publish(every: 30, on: .main, in: .common).autoconnect() + + private var displayCategories: [IptvCategory] { + var list = categories + list.insert( + IptvCategory(id: Self.favoritesId, name: "Favorieten", channelCount: app.store.iptvFavoriteIds.count), + at: 0 + ) + return list + } + + private var filteredChannels: [IptvChannel] { + var list: [IptvChannel] + if selectedCategory == Self.favoritesId { + list = allChannelsCache.filter { app.store.iptvFavoriteIds.contains($0.streamId) } + } else { + list = channels + } + let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if !q.isEmpty { + list = list.filter { + $0.name.lowercased().contains(q) + || ($0.nowTitle?.lowercased().contains(q) ?? false) + || ($0.nextTitle?.lowercased().contains(q) ?? false) + || ("\($0.number ?? -1)".contains(q)) + } + } + let last = app.store.lastIptvStreamId + return list.sorted { a, b in + let af = app.store.isIptvFavorite(a.streamId) + let bf = app.store.isIptvFavorite(b.streamId) + if af != bf { return af && !bf } + if a.streamId == last { return true } + if b.streamId == last { return false } + return (a.number ?? Int.max) < (b.number ?? Int.max) + } + } + + private var focusedChannel: IptvChannel? { + filteredChannels.first { $0.streamId == focusedStreamId } ?? filteredChannels.first + } + + 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: 18) { + Image(systemName: "tv.slash") + .font(.system(size: 48)) + .foregroundStyle(TvColors.textDim) + Text(error).font(TvFonts.body).foregroundStyle(TvColors.danger) + Button("Opnieuw") { Task { await bootstrap() } }.buttonStyle(TvPrimaryButtonStyle()) + } + } else { + mainContent + } + } + .background { + ZStack { + TvColors.bg + RadialGradient( + colors: [TvColors.accent.opacity(0.14), .clear], + center: .topTrailing, + startRadius: 40, + endRadius: 900 + ) + } + .ignoresSafeArea() + } + .task { await bootstrap() } + .onReceive(clockTimer) { clock = $0 } + .onChange(of: focusedStreamId) { _, id in + guard let id else { return } + Task { await loadEpg(streamId: id) } + } + .fullScreenCover(item: $playSession) { session in + TVPlayerView(request: session.request) + .environmentObject(app) + } + } + + private var mainContent: some View { + VStack(alignment: .leading, spacing: 0) { + headerBar + categoryChips + .padding(.top, 10) + .padding(.bottom, 18) + + HStack(alignment: .top, spacing: 28) { + channelColumn + .frame(maxWidth: .infinity) + detailPanel + .frame(width: 480) + .frame(maxHeight: .infinity) + } + .padding(.horizontal, 48) + .padding(.bottom, 36) + } + } + + private var headerBar: some View { + HStack(alignment: .center, spacing: 24) { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 12) { + Text("LIVE TV") + .font(.system(size: 38, weight: .black, design: .rounded)) + .foregroundStyle(TvColors.text) + LivePulseBadge() + } + Text("\(filteredChannels.count) zenders · \(timeLabel)") + .font(TvFonts.caption) + .foregroundStyle(TvColors.textDim) + } + + Spacer(minLength: 20) + + HStack(spacing: 12) { + Image(systemName: "magnifyingglass") + .foregroundStyle(TvColors.textDim) + TextField("Zoek zender of programma", text: $searchText) + .font(TvFonts.body) + .frame(width: 380) + } + .padding(.horizontal, 22) + .padding(.vertical, 16) + .background(.ultraThinMaterial, in: Capsule()) + .overlay(Capsule().strokeBorder(Color.white.opacity(0.08), lineWidth: 1)) + } + .padding(.horizontal, 48) + .padding(.top, 8) + } + + private var timeLabel: String { + let f = DateFormatter() + f.locale = Locale(identifier: "nl_NL") + f.dateFormat = "HH:mm" + return f.string(from: clock) + } + + private var categoryChips: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(displayCategories) { cat in + Button { + selectCategory(cat.id) + } label: { + HStack(spacing: 10) { + if cat.id == Self.favoritesId { + Image(systemName: "star.fill") + .font(.system(size: 16, weight: .bold)) + } + Text(cat.name) + .font(TvFonts.caption) + .lineLimit(1) + if let n = cat.channelCount { + Text("\(n)") + .font(.system(size: 16, weight: .semibold, design: .rounded)) + .foregroundStyle(selectedCategory == cat.id ? .white.opacity(0.85) : TvColors.textDim) + } + } + .foregroundStyle(selectedCategory == cat.id ? .white : TvColors.textDim) + .padding(.horizontal, 22) + .padding(.vertical, 14) + .background( + Capsule().fill(selectedCategory == cat.id ? TvColors.accent : TvColors.surface.opacity(0.9)) + ) + .overlay( + Capsule().strokeBorder( + selectedCategory == cat.id ? Color.clear : Color.white.opacity(0.08), + lineWidth: 1 + ) + ) + } + .buttonStyle(FocusableCardStyle(scale: 1.06)) + } + } + .padding(.horizontal, 48) + .padding(.vertical, 8) + } + .scrollClipDisabled() + .focusSection() + } + + private var channelColumn: some View { + VStack(alignment: .leading, spacing: 14) { + HStack { + Text(selectedCategory == Self.favoritesId ? "Favorieten" : "Zenders") + .font(TvFonts.headline) + .foregroundStyle(TvColors.text) + Spacer() + if channelsLoading { + ProgressView().tint(.white).scaleEffect(0.7) + } + } + + if filteredChannels.isEmpty { + emptyChannels + } else { + ScrollView { + LazyVStack(spacing: 12) { + ForEach(filteredChannels) { ch in + Button { + Task { await play(ch) } + } label: { + LiveChannelRowLabel( + channel: ch, + isFavorite: app.store.isIptvFavorite(ch.streamId), + isLastWatched: app.store.lastIptvStreamId == ch.streamId + ) + } + .buttonStyle(FocusableCardStyle(scale: 1.03)) + .focused($focusedStreamId, equals: ch.streamId) + .onAppear { + if focusedStreamId == nil { + focusedStreamId = ch.streamId + } + } + } + } + .padding(.vertical, 8) + .padding(.trailing, 8) + } + .scrollClipDisabled() + .focusSection() + } + } + } + + private var emptyChannels: some View { + VStack(spacing: 14) { + Image(systemName: selectedCategory == Self.favoritesId ? "star.slash" : "tv") + .font(.system(size: 44)) + .foregroundStyle(TvColors.textDim) + Text(selectedCategory == Self.favoritesId ? "Nog geen favorieten" : "Geen zenders gevonden") + .font(TvFonts.headline) + .foregroundStyle(TvColors.textDim) + Text(selectedCategory == Self.favoritesId + ? "Markeer zenders met ★ in het detailpaneel." + : "Probeer een andere categorie of zoekterm.") + .font(TvFonts.caption) + .foregroundStyle(TvColors.textDim.opacity(0.8)) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var detailPanel: some View { + LiveEpgDetailPanel( + channel: focusedChannel, + epg: epg, + loading: epgLoading, + isFavorite: focusedChannel.map { app.store.isIptvFavorite($0.streamId) } ?? false, + onPlay: { + if let ch = focusedChannel { Task { await play(ch) } } + }, + onToggleFavorite: { + guard let ch = focusedChannel else { return } + _ = app.store.toggleIptvFavorite(ch.streamId) + } + ) + } + + private func selectCategory(_ id: String) { + selectedCategory = id + UserDefaults.standard.set(id, forKey: Self.lastCategoryKey) + focusedStreamId = nil + epg = nil + Task { await loadChannels() } + } + + private func bootstrap() async { + loading = true + error = nil + do { + let raw = try await app.api.loadIptvCategories() + categories = raw.filter { cat in + if cat.id == "__all__" || cat.id == Self.favoritesId { return true } + let n = cat.name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return n != "alles" && n != "all" && n != "alle" + } + let saved = UserDefaults.standard.string(forKey: Self.lastCategoryKey) + if let saved, displayCategories.contains(where: { $0.id == saved }) { + selectedCategory = saved + } else { + selectedCategory = categories.first?.id ?? "__all__" + } + allChannelsCache = (try? await app.api.iptvChannels(categoryId: nil, includeEpg: true)) ?? [] + await loadChannels() + loading = false + } catch { + self.error = error.localizedDescription + loading = false + } + } + + private func loadChannels() async { + channelsLoading = true + defer { channelsLoading = false } + do { + if selectedCategory == Self.favoritesId { + if allChannelsCache.isEmpty { + allChannelsCache = try await app.api.iptvChannels(categoryId: nil, includeEpg: true) + } + channels = allChannelsCache.filter { app.store.iptvFavoriteIds.contains($0.streamId) } + } else { + channels = try await app.api.iptvChannels( + categoryId: selectedCategory == "__all__" ? nil : selectedCategory, + includeEpg: true + ) + } + if focusedStreamId == nil || !filteredChannels.contains(where: { $0.streamId == focusedStreamId }) { + focusedStreamId = filteredChannels.first?.streamId + } + } catch { + self.error = error.localizedDescription + } + } + + private func loadEpg(streamId: Int) async { + epgLoading = true + defer { epgLoading = false } + do { + epg = try await app.api.iptvEpg(streamId: streamId) + } catch { + if let ch = channels.first(where: { $0.streamId == streamId }) + ?? allChannelsCache.first(where: { $0.streamId == streamId }) { + epg = IptvEpg( + streamId: streamId, + channelName: ch.name, + logoUrl: ch.logoUrl, + now: ch.nowTitle.map { EpgProgram(title: $0, start: nil, end: nil) }, + next: ch.nextTitle.map { EpgProgram(title: $0, start: nil, end: nil) } + ) + } else { + epg = nil + } + } + } + + private func play(_ ch: IptvChannel) async { + do { + let info = try await app.api.iptvPlay(streamId: ch.streamId) + guard let url = URL(string: info.streamUrl) else { + error = "Ongeldige stream-URL" + return + } + app.store.setLastIptvStream(ch.streamId) + let fallback = info.fallbackStreamUrl.flatMap(URL.init(string:)) + let list = filteredChannels.isEmpty ? channels : filteredChannels + playSession = LivePlaySession( + request: .live( + url: url, + title: info.name, + fallback: fallback, + logoUrl: info.logoUrl ?? ch.logoUrl, + streamId: ch.streamId, + channels: list, + categoryId: selectedCategory + ) + ) + } catch { + self.error = error.localizedDescription + } + } +} + +private struct LiveChannelRowLabel: View { + let channel: IptvChannel + let isFavorite: Bool + let isLastWatched: Bool + @Environment(\.isFocused) private var isFocused + + var body: some View { + HStack(spacing: 18) { + ZStack { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(Color.white.opacity(0.06)) + .frame(width: 84, height: 84) + RemoteImage(url: channel.logoUrl, contentMode: .fit, opaqueBackground: false) + .frame(width: 58, height: 58) + } + + VStack(alignment: .leading, spacing: 7) { + HStack(spacing: 10) { + if let n = channel.number { + Text(String(format: "%02d", n)) + .font(.system(size: 18, weight: .bold, design: .monospaced)) + .foregroundStyle(TvColors.textDim) + } + Text(channel.name) + .font(TvFonts.headline) + .foregroundStyle(.white) + .lineLimit(1) + if isFavorite { + Image(systemName: "star.fill") + .font(.system(size: 14)) + .foregroundStyle(TvColors.accent) + } + if isLastWatched { + Text("LAATST") + .font(.system(size: 12, weight: .bold, design: .rounded)) + .tracking(0.6) + .foregroundStyle(TvColors.textDim) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(Color.white.opacity(0.08), in: Capsule()) + } + } + + HStack(spacing: 10) { + Text("NU") + .font(.system(size: 13, weight: .bold, design: .rounded)) + .foregroundStyle(TvColors.accent) + Text(channel.nowTitle?.isEmpty == false ? channel.nowTitle! : "Live uitzending") + .font(TvFonts.caption) + .foregroundStyle(isFocused ? TvColors.text : TvColors.textDim) + .lineLimit(1) + } + + if let next = channel.nextTitle, !next.isEmpty { + HStack(spacing: 10) { + Text("STRAKS") + .font(.system(size: 13, weight: .bold, design: .rounded)) + .foregroundStyle(TvColors.textDim.opacity(0.7)) + Text(next) + .font(TvFonts.caption) + .foregroundStyle(TvColors.textDim.opacity(0.85)) + .lineLimit(1) + } + } + } + + Spacer(minLength: 8) + + if isFocused { + LivePulseBadge(compact: true) + } + + Image(systemName: "play.fill") + .font(.system(size: 18, weight: .bold)) + .foregroundStyle(.white) + .frame(width: 48, height: 48) + .background(TvColors.accent, in: Circle()) + .opacity(isFocused ? 1 : 0.55) + } + .padding(.horizontal, 20) + .padding(.vertical, 16) + .background( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(isFocused ? TvColors.surface : TvColors.bgElevated.opacity(0.92)) + ) + .overlay { + RoundedRectangle(cornerRadius: 18, style: .continuous) + .strokeBorder( + isFocused ? TvColors.accent.opacity(0.85) : Color.white.opacity(0.05), + lineWidth: isFocused ? 2.5 : 1 + ) + } + .shadow(color: isFocused ? TvColors.accent.opacity(0.35) : .clear, radius: 22, y: 8) + } +} + +private struct LiveEpgDetailPanel: View { + let channel: IptvChannel? + let epg: IptvEpg? + let loading: Bool + let isFavorite: Bool + let onPlay: () -> Void + let onToggleFavorite: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + if let channel { + ZStack(alignment: .bottomLeading) { + LinearGradient( + colors: [TvColors.accent.opacity(0.35), TvColors.bgElevated], + startPoint: .topTrailing, + endPoint: .bottomLeading + ) + HStack(spacing: 18) { + ZStack { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color.black.opacity(0.35)) + .frame(width: 96, height: 96) + RemoteImage(url: epg?.logoUrl ?? channel.logoUrl, contentMode: .fit, opaqueBackground: false) + .frame(width: 68, height: 68) + } + VStack(alignment: .leading, spacing: 8) { + LivePulseBadge() + Text(epg?.channelName ?? channel.name) + .font(TvFonts.headline) + .foregroundStyle(.white) + .lineLimit(2) + if let n = channel.number { + Text("Kanaal \(n)") + .font(TvFonts.caption) + .foregroundStyle(TvColors.textDim) + } + } + } + .padding(24) + } + .frame(height: 160) + .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous)) + + VStack(alignment: .leading, spacing: 22) { + if loading && epg == nil { + ProgressView("Programma laden…") + .tint(.white) + .foregroundStyle(TvColors.textDim) + .font(TvFonts.caption) + .padding(.top, 8) + } else { + programBlock( + label: "Nu", + title: epg?.now?.title ?? channel.nowTitle ?? "Live uitzending", + time: epg?.now?.timeRangeLabel, + highlight: true + ) + if let progress = epg?.now?.progress, progress > 0 { + ProgressBar(fraction: progress, height: 6) + } + + if let nextTitle = epg?.next?.title ?? channel.nextTitle, !nextTitle.isEmpty { + programBlock( + label: "Straks", + title: nextTitle, + time: epg?.next?.timeRangeLabel, + highlight: false + ) + } else if epg?.now == nil && channel.nowTitle == nil { + Text("Geen programmagids voor deze zender. Je kunt gewoon kijken.") + .font(TvFonts.caption) + .foregroundStyle(TvColors.textDim) + } + } + + Spacer(minLength: 12) + + HStack(spacing: 14) { + Button(action: onPlay) { + Label("Kijken", systemImage: "play.fill") + } + .buttonStyle(TvPrimaryButtonStyle()) + + Button(action: onToggleFavorite) { + Label(isFavorite ? "Favoriet" : "Favoriet", systemImage: isFavorite ? "star.fill" : "star") + } + .buttonStyle(TvSecondaryButtonStyle()) + } + .focusSection() + + Text("OK om te kijken · ★ voor favorieten") + .font(.system(size: 16, weight: .medium, design: .rounded)) + .foregroundStyle(TvColors.textDim.opacity(0.7)) + } + .padding(24) + } else { + VStack(spacing: 12) { + Spacer() + Image(systemName: "sparkles.tv") + .font(.system(size: 44)) + .foregroundStyle(TvColors.textDim) + Text("Selecteer een zender") + .font(TvFonts.headline) + .foregroundStyle(TvColors.textDim) + Text("Programma-info verschijnt hier") + .font(TvFonts.caption) + .foregroundStyle(TvColors.textDim.opacity(0.7)) + Spacer() + } + .frame(maxWidth: .infinity) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background( + RoundedRectangle(cornerRadius: 24, style: .continuous) + .fill(.ultraThinMaterial) + .overlay( + RoundedRectangle(cornerRadius: 24, style: .continuous) + .strokeBorder(Color.white.opacity(0.1), lineWidth: 1) + ) + ) + } + + private func programBlock(label: String, title: String, time: String?, highlight: Bool) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 10) { + Text(label.uppercased()) + .font(.system(size: 14, weight: .bold, design: .rounded)) + .tracking(1.2) + .foregroundStyle(highlight ? TvColors.accent : TvColors.textDim) + if let time { + Text(time) + .font(TvFonts.caption) + .foregroundStyle(TvColors.textDim) + } + } + Text(title) + .font(highlight ? TvFonts.headline : TvFonts.body) + .foregroundStyle(highlight ? .white : TvColors.text.opacity(0.85)) + .lineLimit(3) + } + } +} + +private struct LivePulseBadge: View { + var compact: Bool = false + @State private var pulse = false + + var body: some View { + HStack(spacing: 8) { + Circle() + .fill(TvColors.accent) + .frame(width: compact ? 8 : 10, height: compact ? 8 : 10) + .scaleEffect(pulse ? 1.25 : 1) + .opacity(pulse ? 0.55 : 1) + Text("LIVE") + .font(.system(size: compact ? 13 : 15, weight: .heavy, design: .rounded)) + .tracking(1) + .foregroundStyle(.white) + } + .padding(.horizontal, compact ? 10 : 12) + .padding(.vertical, compact ? 5 : 6) + .background(TvColors.accent.opacity(0.22), in: Capsule()) + .overlay(Capsule().strokeBorder(TvColors.accent.opacity(0.55), lineWidth: 1)) + .onAppear { + withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) { + pulse = true + } + } + } +} + +struct LivePlaySession: Identifiable { + var id: UUID { request.id } + let request: PlayRequest +} diff --git a/apps/ios/VMCTV/UI/MainHub.swift b/apps/ios/VMCTV/UI/MainHub.swift new file mode 100644 index 0000000..0a60cfa --- /dev/null +++ b/apps/ios/VMCTV/UI/MainHub.swift @@ -0,0 +1,40 @@ +import SwiftUI + +struct MainHub: View { + @EnvironmentObject private var app: AppModel + + var body: some View { + ZStack(alignment: .top) { + TvColors.bg.ignoresSafeArea() + + Group { + switch app.tab { + case .home: + NavigationStack { HomeView() } + case .watchlist: + NavigationStack { WatchlistView() } + case .movies: + NavigationStack { BrowseView(kind: "MOVIE", title: "Films") } + case .series: + NavigationStack { BrowseView(kind: "SERIES", title: "Series") } + case .tv: + NavigationStack { LiveTvView() } + case .search: + NavigationStack { SearchView() } + case .settings: + NavigationStack { SettingsView() } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.top, app.isPlaybackActive ? 0 : 92) + + if !app.isPlaybackActive { + TopNav() + .zIndex(10) + .transition(.move(edge: .top).combined(with: .opacity)) + } + } + .animation(.easeOut(duration: 0.22), value: app.isPlaybackActive) + .animation(.easeOut(duration: 0.2), value: app.tab) + } +} diff --git a/apps/ios/VMCTV/UI/ManageProfilesView.swift b/apps/ios/VMCTV/UI/ManageProfilesView.swift new file mode 100644 index 0000000..2cf6ae8 --- /dev/null +++ b/apps/ios/VMCTV/UI/ManageProfilesView.swift @@ -0,0 +1,377 @@ +import SwiftUI + +/// Owner/admin: profiles list + create/edit/delete on Apple TV. +struct ManageProfilesView: View { + @EnvironmentObject private var app: AppModel + @State private var profiles: [ViewerProfile] = [] + @State private var canManage = false + @State private var error: String? + @State private var loading = true + @State private var editing: ViewerProfile? + @State private var showCreate = false + + private let cols = [GridItem(.adaptive(minimum: 220, maximum: 260), spacing: 28)] + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 28) { + Text("Profielen beheren") + .font(TvFonts.title) + .foregroundStyle(TvColors.text) + + Text("Voeg profielen toe, wijzig avatar/PIN of verwijder (niet het hoofdprofiel).") + .font(TvFonts.body) + .foregroundStyle(TvColors.textDim) + + if loading { + ProgressView().tint(.white).padding(.top, 40) + } else if let error, profiles.isEmpty { + Text(error).font(TvFonts.body).foregroundStyle(TvColors.danger) + Button("Opnieuw") { Task { await load() } }.buttonStyle(TvPrimaryButtonStyle()) + } else { + if let error { + Text(error).font(TvFonts.caption).foregroundStyle(TvColors.danger) + } + + LazyVGrid(columns: cols, spacing: 28) { + ForEach(profiles) { profile in + Button { + editing = profile + } label: { + VStack(spacing: 14) { + Text(ProfileAvatars.emoji(for: profile.avatarId)) + .font(.system(size: 64)) + .frame(width: 140, height: 140) + .background(TvColors.surface, in: RoundedRectangle(cornerRadius: 24, style: .continuous)) + Text(profile.name) + .font(TvFonts.headline) + .foregroundStyle(TvColors.text) + .lineLimit(1) + Text(profile.isOwner == true ? "Hoofdprofiel" : (profile.isKids == true ? "Kids" : "Profiel")) + .font(TvFonts.caption) + .foregroundStyle(TvColors.textDim) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } + .buttonStyle(FocusableCardStyle()) + } + + if canManage && profiles.count < 8 { + Button { + showCreate = true + } label: { + VStack(spacing: 14) { + Image(systemName: "plus.circle.fill") + .font(.system(size: 56)) + .foregroundStyle(TvColors.accent) + .frame(width: 140, height: 140) + .background(TvColors.surface.opacity(0.6), in: RoundedRectangle(cornerRadius: 24, style: .continuous)) + Text("Toevoegen") + .font(TvFonts.headline) + .foregroundStyle(TvColors.text) + Text(" ") + .font(TvFonts.caption) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } + .buttonStyle(FocusableCardStyle()) + } + } + .focusSection() + } + } + .padding(.horizontal, 56) + .padding(.bottom, 60) + } + .background(TvColors.bg.ignoresSafeArea()) + .task { await load() } + .sheet(item: $editing) { profile in + EditProfileSheet(profile: profile, canManage: canManage) { + editing = nil + Task { await load() } + } + .environmentObject(app) + } + .sheet(isPresented: $showCreate) { + CreateProfileSheetTV { + showCreate = false + Task { await load() } + } + .environmentObject(app) + } + } + + private func load() async { + loading = true + error = nil + do { + let payload = try await app.api.listProfiles() + profiles = payload.profiles + canManage = payload.canManage == true + app.canManageProfiles = canManage + loading = false + } catch { + self.error = error.localizedDescription + loading = false + } + } +} + +struct CreateProfileSheetTV: 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 + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 28) { + TextField("Naam", text: $name) + .font(TvFonts.body) + + Text("Avatar") + .font(TvFonts.headline) + .foregroundStyle(TvColors.text) + LazyVGrid(columns: [GridItem(.adaptive(minimum: 72), spacing: 14)], spacing: 14) { + ForEach(ProfileAvatars.all, id: \.id) { avatar in + Button { avatarId = avatar.id } label: { + Text(avatar.emoji) + .font(.system(size: 36)) + .frame(width: 72, height: 72) + .background( + avatarId == avatar.id ? TvColors.accent.opacity(0.3) : TvColors.surface, + in: Circle() + ) + .overlay { + Circle().strokeBorder( + avatarId == avatar.id ? TvColors.accent : Color.clear, + lineWidth: 3 + ) + } + } + .buttonStyle(FocusableCardStyle()) + } + } + .focusSection() + + Toggle("Kids-profiel", isOn: $isKids) + .tint(TvColors.accent) + .font(TvFonts.body) + + TextField("PIN (optioneel, 4 cijfers)", text: $pin) + .font(TvFonts.body) + + if let error { + Text(error).font(TvFonts.caption).foregroundStyle(TvColors.danger) + } + + Button { + Task { await save() } + } label: { + Text(saving ? "Bezig…" : "Aanmaken") + } + .buttonStyle(TvPrimaryButtonStyle()) + .disabled(saving || name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + .padding(40) + } + .background(TvColors.bg) + .navigationTitle("Nieuw profiel") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Sluiten", action: onDone) + } + } + } + } + + private func save() async { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + let pinDigits = pin.filter(\.isNumber) + if !pin.isEmpty && pinDigits.count != 4 { + error = "PIN moet 4 cijfers zijn" + return + } + saving = true + error = nil + do { + _ = try await app.api.createProfile( + name: trimmed, + avatarId: avatarId, + isKids: isKids, + pin: pinDigits.count == 4 ? pinDigits : nil + ) + saving = false + onDone() + } catch { + self.error = error.localizedDescription + saving = false + } + } +} + +struct EditProfileSheet: View { + @EnvironmentObject private var app: AppModel + let profile: ViewerProfile + let canManage: Bool + let onDone: () -> Void + + @State private var name: String + @State private var avatarId: String + @State private var isKids: Bool + @State private var pin = "" + @State private var clearPin = false + @State private var error: String? + @State private var saving = false + @State private var deleting = false + + init(profile: ViewerProfile, canManage: Bool, onDone: @escaping () -> Void) { + self.profile = profile + self.canManage = canManage + self.onDone = onDone + _name = State(initialValue: profile.name) + _avatarId = State(initialValue: profile.avatarId ?? "fox") + _isKids = State(initialValue: profile.isKids == true) + } + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 28) { + TextField("Naam", text: $name) + .font(TvFonts.body) + + Text("Avatar") + .font(TvFonts.headline) + .foregroundStyle(TvColors.text) + LazyVGrid(columns: [GridItem(.adaptive(minimum: 72), spacing: 14)], spacing: 14) { + ForEach(ProfileAvatars.all, id: \.id) { avatar in + Button { avatarId = avatar.id } label: { + Text(avatar.emoji) + .font(.system(size: 36)) + .frame(width: 72, height: 72) + .background( + avatarId == avatar.id ? TvColors.accent.opacity(0.3) : TvColors.surface, + in: Circle() + ) + .overlay { + Circle().strokeBorder( + avatarId == avatar.id ? TvColors.accent : Color.clear, + lineWidth: 3 + ) + } + } + .buttonStyle(FocusableCardStyle()) + } + } + .focusSection() + + if canManage && profile.isOwner != true { + Toggle("Kids-profiel", isOn: $isKids) + .tint(TvColors.accent) + .font(TvFonts.body) + } + + TextField("Nieuwe PIN (leeg = ongewijzigd)", text: $pin) + .font(TvFonts.body) + if profile.hasPin == true { + Toggle("PIN verwijderen", isOn: $clearPin) + .tint(TvColors.accent) + .font(TvFonts.body) + } + + if let error { + Text(error).font(TvFonts.caption).foregroundStyle(TvColors.danger) + } + + Button { + Task { await save() } + } label: { + Text(saving ? "Opslaan…" : "Opslaan") + } + .buttonStyle(TvPrimaryButtonStyle()) + .disabled(saving) + + if canManage && profile.isOwner != true { + Button(role: .destructive) { + Task { await remove() } + } label: { + Text(deleting ? "Verwijderen…" : "Profiel verwijderen") + } + .buttonStyle(TvSecondaryButtonStyle()) + .disabled(deleting) + } + } + .padding(40) + } + .background(TvColors.bg) + .navigationTitle(profile.name) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Sluiten", action: onDone) + } + } + } + } + + private func save() async { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + error = "Naam verplicht" + return + } + let pinDigits = pin.filter(\.isNumber) + if !pin.isEmpty && pinDigits.count != 4 { + error = "PIN moet 4 cijfers zijn" + return + } + saving = true + error = nil + do { + _ = try await app.api.updateProfile( + profileId: profile.id, + name: trimmed, + avatarId: avatarId, + isKids: canManage && profile.isOwner != true ? isKids : nil, + pin: (!clearPin && pinDigits.count == 4) ? pinDigits : nil, + clearPin: clearPin + ) + if profile.id == app.store.profileId { + app.store.setProfile( + id: profile.id, + name: trimmed, + avatarId: avatarId, + showAvatar: profile.showsAvatar, + avatarUrl: profile.avatarUrl ?? "" + ) + } + saving = false + onDone() + } catch { + self.error = error.localizedDescription + saving = false + } + } + + private func remove() async { + deleting = true + error = nil + do { + try await app.api.deleteProfile(profileId: profile.id) + deleting = false + onDone() + } catch { + self.error = error.localizedDescription + deleting = false + } + } +} diff --git a/apps/ios/VMCTV/UI/ProfilesView.swift b/apps/ios/VMCTV/UI/ProfilesView.swift new file mode 100644 index 0000000..dd93309 --- /dev/null +++ b/apps/ios/VMCTV/UI/ProfilesView.swift @@ -0,0 +1,232 @@ +import SwiftUI + +struct ProfilesView: 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: 200, maximum: 240), spacing: 36)] + + var body: some View { + ZStack { + LinearGradient( + colors: [Color(red: 0.08, green: 0.02, blue: 0.03), TvColors.bg], + startPoint: .top, + endPoint: .bottom + ) + .ignoresSafeArea() + + VStack(spacing: 40) { + Text("Wie kijkt er?") + .font(TvFonts.title) + .foregroundStyle(TvColors.text) + .padding(.top, 60) + + if loading { + ProgressView().tint(.white).scaleEffect(1.5) + } else if let error, profiles.isEmpty { + Text(error).font(TvFonts.body).foregroundStyle(TvColors.danger) + Button("Opnieuw") { Task { await load() } }.buttonStyle(TvPrimaryButtonStyle()) + } else { + if let error { + Text(error).font(TvFonts.caption).foregroundStyle(TvColors.danger) + } + LazyVGrid(columns: cols, spacing: 40) { + ForEach(profiles) { profile in + Button { + Task { await select(profile) } + } label: { + VStack(spacing: 16) { + Text(ProfileAvatars.emoji(for: profile.avatarId)) + .font(.system(size: 72)) + .frame(width: 160, height: 160) + .background( + LinearGradient( + colors: [TvColors.surfaceSoft, TvColors.surface], + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + in: RoundedRectangle(cornerRadius: 28, style: .continuous) + ) + .overlay( + RoundedRectangle(cornerRadius: 28, style: .continuous) + .stroke(TvColors.accent.opacity(0.3), lineWidth: 2) + ) + Text(profile.name) + .font(TvFonts.headline) + .foregroundStyle(TvColors.text) + .lineLimit(1) + if profile.hasPin == true { + Image(systemName: "lock.fill") + .font(.system(size: 18)) + .foregroundStyle(TvColors.textDim) + } + } + } + .buttonStyle(FocusableCardStyle()) + .disabled(selecting) + } + } + .padding(.horizontal, 64) + .focusSection() + } + + Spacer() + } + + if let pinTarget { + PinPadOverlay( + profileName: pinTarget.name, + avatarId: pinTarget.avatarId, + digits: $pinDigits, + error: pinError, + busy: selecting, + onCancel: { + self.pinTarget = nil + pinDigits = "" + pinError = nil + }, + onSubmit: { code in + Task { await submitPin(for: pinTarget, code: code) } + } + ) + .transition(.opacity) + .zIndex(20) + } + } + .animation(.easeOut(duration: 0.2), value: pinTarget?.id) + .task { await load() } + } + + 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 PinPadOverlay: 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 { + ZStack { + Color.black.opacity(0.72).ignoresSafeArea() + VStack(spacing: 28) { + Text(ProfileAvatars.emoji(for: avatarId)) + .font(.system(size: 64)) + Text("PIN voor \(profileName)") + .font(TvFonts.headline) + .foregroundStyle(TvColors.text) + + HStack(spacing: 18) { + ForEach(0..<4, id: \.self) { i in + Circle() + .fill(i < digits.count ? TvColors.accent : TvColors.surfaceSoft) + .frame(width: 22, height: 22) + .shadow(color: i < digits.count ? TvColors.accent.opacity(0.5) : .clear, radius: 8) + } + } + + if let error { + Text(error).font(TvFonts.caption).foregroundStyle(TvColors.danger) + } + + LazyVGrid(columns: Array(repeating: GridItem(.fixed(100), spacing: 16), count: 3), spacing: 16) { + ForEach(keys, id: \.self) { key in + if key.isEmpty { + Color.clear.frame(width: 100, height: 72) + } else { + Button { tap(key) } label: { + Text(key) + .font(.system(size: 32, weight: .semibold, design: .rounded)) + .foregroundStyle(TvColors.text) + .frame(width: 100, height: 72) + .background(TvColors.surface, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + } + .buttonStyle(FocusableCardStyle()) + .disabled(busy) + } + } + } + .focusSection() + + Button("Annuleer", action: onCancel) + .buttonStyle(TvSecondaryButtonStyle()) + + if busy { + ProgressView().tint(.white) + } + } + .padding(48) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 28, style: .continuous)) + } + } + + private func tap(_ key: String) { + if key == "⌫" { + if !digits.isEmpty { digits.removeLast() } + return + } + guard digits.count < 4 else { return } + digits.append(key) + if digits.count == 4 { + onSubmit(digits) + } + } +} diff --git a/apps/ios/VMCTV/UI/RootView.swift b/apps/ios/VMCTV/UI/RootView.swift new file mode 100644 index 0000000..6d33e7e --- /dev/null +++ b/apps/ios/VMCTV/UI/RootView.swift @@ -0,0 +1,68 @@ +import SwiftUI + +struct RootView: View { + @EnvironmentObject private var app: AppModel + + var body: some View { + ZStack { + TvColors.bg.ignoresSafeArea() + switch app.phase { + case .boot: + SplashView() + case .server: + ServerChoiceView() + case .auth: + AuthChoiceView() + case .password: + PasswordLoginView() + case .link: + LinkView() + case .profiles: + ProfilesView() + case .main: + MainHub() + } + } + .animation(.easeInOut(duration: 0.32), value: app.phase) + } +} + +struct SplashView: View { + @State private var pulse = false + @State private var glow = false + + var body: some View { + ZStack { + LinearGradient( + colors: [ + Color(red: 0.1, green: 0.02, blue: 0.04), + TvColors.bg, + ], + startPoint: .top, + endPoint: .bottom + ) + .ignoresSafeArea() + + VStack(spacing: 28) { + Text("VMC") + .font(TvFonts.brand) + .foregroundStyle(TvColors.accent) + .scaleEffect(pulse ? 1.06 : 0.94) + .shadow(color: TvColors.accent.opacity(glow ? 0.65 : 0.25), radius: glow ? 42 : 12) + Text("Vonas Media Cluster") + .font(TvFonts.body) + .foregroundStyle(TvColors.textDim) + .opacity(pulse ? 1 : 0.7) + ProgressView() + .tint(TvColors.textDim) + .scaleEffect(1.3) + } + } + .onAppear { + withAnimation(.easeInOut(duration: 1.2).repeatForever(autoreverses: true)) { + pulse = true + glow = true + } + } + } +} diff --git a/apps/ios/VMCTV/UI/SearchView.swift b/apps/ios/VMCTV/UI/SearchView.swift new file mode 100644 index 0000000..8e5ae89 --- /dev/null +++ b/apps/ios/VMCTV/UI/SearchView.swift @@ -0,0 +1,97 @@ +import SwiftUI + +struct SearchView: 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: 168, maximum: 200), spacing: 24) + ] + + var body: some View { + VStack(alignment: .leading, spacing: 24) { + Text("Zoeken") + .font(TvFonts.title) + .foregroundStyle(TvColors.text) + .padding(.horizontal, 48) + .padding(.top, 12) + + TextField("Films, series…", text: $query) + .font(TvFonts.headline) + .foregroundStyle(TvColors.text) + .padding(.horizontal, 28) + .padding(.vertical, 20) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .strokeBorder(Color.white.opacity(0.12), lineWidth: 1) + ) + .padding(.horizontal, 48) + .onSubmit { Task { await runSearch() } } + + Group { + if searching { + ProgressView().tint(.white).frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error { + Text(error).font(TvFonts.body).foregroundStyle(TvColors.danger).padding(.horizontal, 48) + Spacer() + } else if results.isEmpty && query.count >= 2 { + Text("Geen resultaten") + .font(TvFonts.headline) + .foregroundStyle(TvColors.textDim) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if results.isEmpty { + Text("Typ minstens 2 tekens om te zoeken.") + .font(TvFonts.body) + .foregroundStyle(TvColors.textDim) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + LazyVGrid(columns: cols, spacing: 28) { + ForEach(results) { item in + NavigationLink(value: item) { + PosterCard(item: item, width: 168) + } + .buttonStyle(FocusableCardStyle()) + } + } + .padding(.horizontal, 48) + .padding(.bottom, 48) + } + .focusSection() + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(TvColors.bg.ignoresSafeArea()) + .navigationDestination(for: MediaItem.self) { MediaRouter(item: $0) } + .onChange(of: query) { _, newValue in + Task { + try? await Task.sleep(nanoseconds: 400_000_000) + guard newValue == query else { return } + await runSearch() + } + } + } + + private func runSearch() async { + let q = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard q.count >= 2 else { + results = [] + error = nil + return + } + searching = true + error = nil + do { + results = try await app.api.search(query: q) + searching = false + } catch { + self.error = error.localizedDescription + searching = false + } + } +} diff --git a/apps/ios/VMCTV/UI/SettingsView.swift b/apps/ios/VMCTV/UI/SettingsView.swift new file mode 100644 index 0000000..621c372 --- /dev/null +++ b/apps/ios/VMCTV/UI/SettingsView.swift @@ -0,0 +1,399 @@ +import SwiftUI + +struct SettingsView: View { + @EnvironmentObject private var app: AppModel + @State private var prefs: [ShelfPref] = [] + @State private var prefsError: String? + @State private var prefsMessage: String? + @State private var savingPrefs = false + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 36) { + VStack(alignment: .leading, spacing: 8) { + Text("Instellingen") + .font(TvFonts.title) + .foregroundStyle(TvColors.text) + Text("Profiel, home-planken en account.") + .font(TvFonts.body) + .foregroundStyle(TvColors.textDim) + } + + profileHero + + settingsSection(title: "Profiel", subtitle: "Wissel of beheer kijkprofielen") { + HStack(spacing: 20) { + settingsActionButton( + title: "Profiel wisselen", + systemImage: "person.2.fill" + ) { + Task { await app.switchProfile() } + } + + NavigationLink { + ManageProfilesView() + } label: { + settingsActionLabel( + title: app.canManageProfiles ? "Profielen beheren" : "Profielen bekijken", + systemImage: "person.crop.circle.badge.plus" + ) + } + .buttonStyle(FocusableCardStyle(scale: 1.04)) + } + .focusSection() + } + + settingsSection( + title: "Home-planken", + subtitle: "Zet planken aan/uit en bepaal de volgorde op Home" + ) { + if prefs.isEmpty { + Text(prefsError ?? "Geen planken geladen") + .font(TvFonts.caption) + .foregroundStyle(TvColors.textDim) + } else { + VStack(spacing: 12) { + ForEach(Array(prefs.enumerated()), id: \.element.id) { index, pref in + ShelfPrefRow( + pref: pref, + index: index, + total: prefs.count, + onToggle: { + prefs[index].visible.toggle() + Task { await persistPrefs() } + }, + onMoveUp: { + movePref(from: index, to: index - 1) + }, + onMoveDown: { + movePref(from: index, to: index + 1) + } + ) + } + } + .focusSection() + + HStack(spacing: 16) { + if savingPrefs { + ProgressView().tint(.white).scaleEffect(0.8) + } + if let prefsMessage { + Label(prefsMessage, systemImage: "checkmark.circle.fill") + .font(TvFonts.caption) + .foregroundStyle(TvColors.success) + } + if let prefsError { + Text(prefsError) + .font(TvFonts.caption) + .foregroundStyle(TvColors.danger) + } + } + .padding(.top, 4) + } + } + + settingsSection(title: "Account", subtitle: "Sessie op dit apparaat") { + settingsActionButton( + title: "Uitloggen", + systemImage: "rectangle.portrait.and.arrow.right", + destructive: true + ) { + Task { await app.logout() } + } + } + + settingsSection(title: "Over deze app", subtitle: nil) { + VStack(spacing: 0) { + infoRow("App", "Vonas Media Cluster · Apple TV") + divider + infoRow("Versie", "1.0.0") + divider + infoRow("Platform", "apple_tv") + divider + infoRow("Live TV", app.iptvEnabled ? "Aan" : "Uit") + divider + infoRow("Apparaat", AppModel.deviceName) + divider + infoRow("Server", app.store.apiBase) + } + } + } + .padding(.horizontal, 56) + .padding(.top, 8) + .padding(.bottom, 72) + } + .background( + ZStack { + TvColors.bg + LinearGradient( + colors: [TvColors.accent.opacity(0.12), .clear, .clear], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + .ignoresSafeArea() + ) + .task { + await loadPrefs() + if let payload = try? await app.api.listProfiles() { + app.canManageProfiles = payload.canManage == true + } + } + } + + private var profileHero: some View { + HStack(spacing: 28) { + Text(ProfileAvatars.emoji(for: app.store.profileAvatarId)) + .font(.system(size: 64)) + .frame(width: 112, height: 112) + .background( + LinearGradient( + colors: [TvColors.surfaceSoft, TvColors.surface], + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + in: Circle() + ) + .overlay(Circle().strokeBorder(TvColors.accent.opacity(0.45), lineWidth: 2)) + + VStack(alignment: .leading, spacing: 10) { + Text(app.store.profileName ?? "Profiel") + .font(TvFonts.headline) + .foregroundStyle(TvColors.text) + Text("Actief kijkprofiel") + .font(TvFonts.caption) + .foregroundStyle(TvColors.textDim) + Text(app.store.apiBase) + .font(.system(size: 18, weight: .medium, design: .monospaced)) + .foregroundStyle(TvColors.textDim.opacity(0.9)) + .lineLimit(1) + } + Spacer(minLength: 0) + } + .padding(32) + .background( + RoundedRectangle(cornerRadius: 24, style: .continuous) + .fill(.ultraThinMaterial) + .overlay( + RoundedRectangle(cornerRadius: 24, style: .continuous) + .strokeBorder( + LinearGradient( + colors: [TvColors.accent.opacity(0.45), Color.white.opacity(0.08)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + lineWidth: 1.5 + ) + ) + ) + } + + private func settingsSection( + title: String, + subtitle: String?, + @ViewBuilder content: () -> Content + ) -> some View { + VStack(alignment: .leading, spacing: 18) { + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(TvFonts.headline) + .foregroundStyle(TvColors.text) + if let subtitle { + Text(subtitle) + .font(TvFonts.caption) + .foregroundStyle(TvColors.textDim) + } + } + content() + .padding(24) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 22, style: .continuous) + .fill(TvColors.bgElevated.opacity(0.92)) + .overlay( + RoundedRectangle(cornerRadius: 22, style: .continuous) + .strokeBorder(Color.white.opacity(0.07), lineWidth: 1) + ) + ) + } + } + + private func settingsActionButton( + title: String, + systemImage: String, + destructive: Bool = false, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + settingsActionLabel(title: title, systemImage: systemImage, destructive: destructive) + } + .buttonStyle(FocusableCardStyle(scale: 1.04)) + } + + private func settingsActionLabel( + title: String, + systemImage: String, + destructive: Bool = false + ) -> some View { + HStack(spacing: 14) { + Image(systemName: systemImage) + .font(.system(size: 24, weight: .semibold)) + .foregroundStyle(destructive ? TvColors.danger : TvColors.accent) + .frame(width: 36) + Text(title) + .font(TvFonts.body) + .foregroundStyle(TvColors.text) + Spacer(minLength: 8) + Image(systemName: "chevron.right") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(TvColors.textDim) + } + .padding(.horizontal, 22) + .padding(.vertical, 18) + .frame(minWidth: 320) + .background(TvColors.surface, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .strokeBorder(Color.white.opacity(0.08), lineWidth: 1) + ) + } + + private var divider: some View { + Rectangle() + .fill(Color.white.opacity(0.06)) + .frame(height: 1) + .padding(.vertical, 4) + } + + private func infoRow(_ label: String, _ value: String) -> some View { + HStack(alignment: .firstTextBaseline) { + Text(label) + .font(TvFonts.body) + .foregroundStyle(TvColors.textDim) + .frame(width: 160, alignment: .leading) + Text(value) + .font(TvFonts.body) + .foregroundStyle(TvColors.text) + .frame(maxWidth: .infinity, alignment: .trailing) + .multilineTextAlignment(.trailing) + } + .padding(.vertical, 10) + } + + private func movePref(from: Int, to: Int) { + guard prefs.indices.contains(from), prefs.indices.contains(to) else { return } + withAnimation(.easeOut(duration: 0.2)) { + prefs.swapAt(from, to) + } + Task { await persistPrefs() } + } + + private func loadPrefs() async { + do { + prefs = try await app.api.getShelfPrefs().sorted { $0.sortOrder < $1.sortOrder } + prefsError = nil + } catch { + prefsError = error.localizedDescription + } + } + + private func persistPrefs() async { + savingPrefs = true + 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) + prefsMessage = "Opgeslagen" + prefsError = nil + savingPrefs = false + try? await Task.sleep(nanoseconds: 1_800_000_000) + if prefsMessage == "Opgeslagen" { prefsMessage = nil } + } catch { + prefsError = error.localizedDescription + prefsMessage = nil + savingPrefs = false + } + } +} + +private struct ShelfPrefRow: View { + let pref: ShelfPref + let index: Int + let total: Int + let onToggle: () -> Void + let onMoveUp: () -> Void + let onMoveDown: () -> Void + + var body: some View { + HStack(spacing: 18) { + Text("\(index + 1)") + .font(.system(size: 22, weight: .bold, design: .rounded)) + .foregroundStyle(TvColors.textDim) + .frame(width: 36) + + VStack(alignment: .leading, spacing: 4) { + Text(pref.name) + .font(TvFonts.body) + .foregroundStyle(TvColors.text) + Text(pref.visible ? "Zichtbaar op Home" : "Verborgen") + .font(TvFonts.caption) + .foregroundStyle(pref.visible ? TvColors.success.opacity(0.9) : TvColors.textDim) + } + + Spacer(minLength: 12) + + HStack(spacing: 10) { + Button(action: onMoveUp) { + Image(systemName: "chevron.up") + .font(.system(size: 20, weight: .bold)) + .frame(width: 52, height: 52) + .background(TvColors.surfaceSoft, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + .buttonStyle(FocusableCardStyle(scale: 1.08)) + .disabled(index == 0) + .opacity(index == 0 ? 0.35 : 1) + + Button(action: onMoveDown) { + Image(systemName: "chevron.down") + .font(.system(size: 20, weight: .bold)) + .frame(width: 52, height: 52) + .background(TvColors.surfaceSoft, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + .buttonStyle(FocusableCardStyle(scale: 1.08)) + .disabled(index >= total - 1) + .opacity(index >= total - 1 ? 0.35 : 1) + + Button(action: onToggle) { + Text(pref.visible ? "Aan" : "Uit") + .font(.system(size: 20, weight: .semibold, design: .rounded)) + .foregroundStyle(pref.visible ? .white : TvColors.textDim) + .frame(width: 88, height: 52) + .background( + pref.visible ? TvColors.accent : TvColors.surfaceSoft, + in: RoundedRectangle(cornerRadius: 12, style: .continuous) + ) + } + .buttonStyle(FocusableCardStyle(scale: 1.08)) + } + } + .padding(.horizontal, 18) + .padding(.vertical, 14) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(TvColors.surface.opacity(0.85)) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .strokeBorder(Color.white.opacity(0.06), lineWidth: 1) + ) + ) + } +} diff --git a/apps/ios/VMCTV/UI/TopNav.swift b/apps/ios/VMCTV/UI/TopNav.swift new file mode 100644 index 0000000..90e76f8 --- /dev/null +++ b/apps/ios/VMCTV/UI/TopNav.swift @@ -0,0 +1,170 @@ +import SwiftUI + +struct TopNav: View { + @EnvironmentObject private var app: AppModel + @State private var now = Date() + + private let clockTimer = Timer.publish(every: 30, on: .main, in: .common).autoconnect() + + var body: some View { + HStack(spacing: 8) { + BrandLogo(size: 34) + .padding(.trailing, 20) + + HStack(spacing: 10) { + ForEach(visibleTabs) { tab in + if tab == .search || tab == .settings { + navIconButton(tab) + } else { + navTextButton(tab) + } + } + } + .focusSection() + + Spacer(minLength: 16) + + Text(timeString) + .font(.system(size: 22, weight: .semibold, design: .rounded)) + .foregroundStyle(TvColors.textDim) + .monospacedDigit() + .padding(.trailing, 12) + + Button { + app.tab = .settings + } label: { + FocusAwareProfileBadge(avatarId: app.store.profileAvatarId) + } + .buttonStyle(NavTabButtonStyle()) + } + .padding(.horizontal, 48) + .padding(.vertical, 18) + .background { + Rectangle() + .fill(.ultraThinMaterial) + .overlay( + LinearGradient( + colors: [Color.black.opacity(0.55), Color.black.opacity(0.15)], + startPoint: .top, + endPoint: .bottom + ) + ) + .ignoresSafeArea(edges: .top) + } + .onReceive(clockTimer) { now = $0 } + } + + private var timeString: String { + let f = DateFormatter() + f.locale = Locale(identifier: "nl_NL") + f.dateFormat = "HH:mm" + return f.string(from: now) + } + + private var visibleTabs: [AppModel.MainTab] { + AppModel.MainTab.allCases.filter { tab in + if tab == .tv { return app.iptvEnabled } + return true + } + } + + private func navTextButton(_ tab: AppModel.MainTab) -> some View { + Button { + withAnimation(.easeOut(duration: 0.2)) { app.tab = tab } + } label: { + FocusAwareNavLabel(title: tab.title, selected: app.tab == tab) + } + .buttonStyle(NavTabButtonStyle()) + } + + private func navIconButton(_ tab: AppModel.MainTab) -> some View { + Button { + withAnimation(.easeOut(duration: 0.2)) { app.tab = tab } + } label: { + FocusAwareNavIcon(systemName: tab.icon, selected: app.tab == tab) + } + .buttonStyle(NavTabButtonStyle()) + .accessibilityLabel(tab.title) + } +} + +private struct FocusAwareNavLabel: View { + let title: String + let selected: Bool + @Environment(\.isFocused) private var isFocused + + var body: some View { + Text(title) + .font(TvFonts.nav) + .foregroundStyle(isFocused || selected ? TvColors.text : TvColors.textDim) + .padding(.horizontal, 20) + .padding(.vertical, 12) + .background( + Capsule().fill(backgroundFill) + ) + .overlay { + Capsule() + .strokeBorder(borderColor, lineWidth: isFocused ? 3 : (selected ? 1.5 : 0)) + } + } + + private var backgroundFill: Color { + if isFocused { return Color.white.opacity(0.18) } + if selected { return TvColors.accent.opacity(0.28) } + return .clear + } + + private var borderColor: Color { + if isFocused { return Color.white } + if selected { return TvColors.accent.opacity(0.7) } + return .clear + } +} + +private struct FocusAwareNavIcon: View { + let systemName: String + let selected: Bool + @Environment(\.isFocused) private var isFocused + + var body: some View { + Image(systemName: systemName) + .font(.system(size: 24, weight: .semibold)) + .foregroundStyle(isFocused || selected ? Color.white : TvColors.textDim) + .frame(width: 54, height: 54) + .background(Circle().fill(backgroundFill)) + .overlay { + Circle() + .strokeBorder(borderColor, lineWidth: isFocused ? 3 : (selected ? 1.5 : 0)) + } + } + + private var backgroundFill: Color { + if isFocused { return Color.white.opacity(0.18) } + if selected { return TvColors.accent.opacity(0.28) } + return .clear + } + + private var borderColor: Color { + if isFocused { return Color.white } + if selected { return TvColors.accent.opacity(0.7) } + return .clear + } +} + +private struct FocusAwareProfileBadge: View { + let avatarId: String? + @Environment(\.isFocused) private var isFocused + + var body: some View { + Text(ProfileAvatars.emoji(for: avatarId)) + .font(.system(size: 32)) + .frame(width: 56, height: 56) + .background(.ultraThinMaterial, in: Circle()) + .overlay( + Circle().strokeBorder( + isFocused ? Color.white : TvColors.accent.opacity(0.45), + lineWidth: isFocused ? 3 : 1.5 + ) + ) + } +} diff --git a/apps/ios/VMCTV/UI/WatchlistView.swift b/apps/ios/VMCTV/UI/WatchlistView.swift new file mode 100644 index 0000000..da2a658 --- /dev/null +++ b/apps/ios/VMCTV/UI/WatchlistView.swift @@ -0,0 +1,75 @@ +import SwiftUI + +struct WatchlistView: View { + @EnvironmentObject private var app: AppModel + @State private var loading = false + + private let cols = [ + GridItem(.adaptive(minimum: 168, maximum: 200), spacing: 24) + ] + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + Text("Watchlist") + .font(TvFonts.title) + .foregroundStyle(TvColors.text) + .padding(.horizontal, 48) + .padding(.top, 12) + + if loading && app.favorites.mediaItems.isEmpty { + ProgressView().tint(.white).frame(maxWidth: .infinity, maxHeight: .infinity) + } else if app.favorites.mediaItems.isEmpty { + VStack(spacing: 16) { + Image(systemName: "bookmark") + .font(.system(size: 56)) + .foregroundStyle(TvColors.textDim) + Text("Nog geen favorieten") + .font(TvFonts.headline) + .foregroundStyle(TvColors.textDim) + Text("Voeg films of series toe vanaf de detailpagina.") + .font(TvFonts.body) + .foregroundStyle(TvColors.textDim.opacity(0.8)) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + LazyVGrid(columns: cols, spacing: 28) { + ForEach(app.favorites.mediaItems) { item in + NavigationLink(value: item) { + PosterCard(item: item, width: 168) + } + .buttonStyle(FocusableCardStyle()) + .contextMenu { + Button("Verwijderen uit watchlist", role: .destructive) { + Task { + _ = await app.favorites.toggle( + type: item.type, + id: item.id, + title: item.title, + year: item.year, + posterUrl: item.posterUrl, + backdropUrl: item.backdropUrl, + logoUrl: item.logoUrl, + api: app.api + ) + } + } + } + } + } + .padding(.horizontal, 48) + .padding(.bottom, 48) + } + .focusSection() + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(TvColors.bg.ignoresSafeArea()) + .navigationDestination(for: MediaItem.self) { MediaRouter(item: $0) } + .task { + loading = true + await app.favorites.refresh(using: app.api) + loading = false + } + } +} diff --git a/apps/ios/VMCTV/VMCTVApp.swift b/apps/ios/VMCTV/VMCTVApp.swift new file mode 100644 index 0000000..18760c1 --- /dev/null +++ b/apps/ios/VMCTV/VMCTVApp.swift @@ -0,0 +1,24 @@ +import SwiftUI + +@main +struct VMCTVApp: App { + @StateObject private var app = AppModel() + + init() { + // AsyncImage deelt URLSession.shared — grotere cache zodat Home-posters blijven hangen. + let cache = URLCache( + memoryCapacity: 64 * 1024 * 1024, + diskCapacity: 512 * 1024 * 1024, + diskPath: "vmctv-images" + ) + URLCache.shared = cache + } + + var body: some Scene { + WindowGroup { + RootView() + .environmentObject(app) + .preferredColorScheme(.dark) + } + } +} \ No newline at end of file diff --git a/apps/ios/scripts/fetch-tvvlckit.sh b/apps/ios/scripts/fetch-tvvlckit.sh new file mode 100644 index 0000000..1808418 --- /dev/null +++ b/apps/ios/scripts/fetch-tvvlckit.sh @@ -0,0 +1,46 @@ +#!/bin/zsh +set -euo pipefail +# Download newest TVVLCKit 3.x xcframework from VideoLAN cocoapods prod (or artifacts). +cd /tmp +echo "Listing TVVLCKit packages (cocoapods prod)..." +curl -fsSL https://download.videolan.org/pub/cocoapods/prod/ -o /tmp/tvvlc-prod.html || \ + curl -fsSL https://download.videolan.org/pub/pub/cocoapods/prod/ -o /tmp/tvvlc-prod.html + +python3 - <<'PY' +from pathlib import Path +import re +html = Path('/tmp/tvvlc-prod.html').read_text(errors='ignore') +names = sorted(set(re.findall(r'href="(TVVLCKit-3\.[^"]+)"', html))) +for n in names[-20:]: + print(n) +pick = None +for n in reversed(names): + if n.startswith('TVVLCKit-3.6') or n.startswith('TVVLCKit-3.5') or n.startswith('TVVLCKit-3.7'): + pick = n + break +if not pick and names: + pick = names[-1] +print('PICK=' + (pick or '')) +Path('/tmp/tvvlc-pick.txt').write_text(pick or '') +PY + +PICK=$(cat /tmp/tvvlc-pick.txt) +BASE="https://download.videolan.org/pub/cocoapods/prod" +if [ -z "$PICK" ]; then + echo "Falling back to artifacts.videolan.org TVVLCKit 3.7.3..." + PICK="TVVLCKit-3.7.3-319ed2c0-79128878.tar.xz" + BASE="https://artifacts.videolan.org/VLCKit/TVVLCKit" +fi + +echo "Downloading $PICK ..." +curl -fL --progress-bar "$BASE/$PICK" -o "/tmp/$PICK" +mkdir -p /tmp/tvvlcunpack +rm -rf /tmp/tvvlcunpack/* +case "$PICK" in + *.zip) unzip -q "/tmp/$PICK" -d /tmp/tvvlcunpack ;; + *.tar.xz|*.txz) tar -xJf "/tmp/$PICK" -C /tmp/tvvlcunpack ;; + *.tar.bz2) tar -xjf "/tmp/$PICK" -C /tmp/tvvlcunpack ;; + *) tar -xf "/tmp/$PICK" -C /tmp/tvvlcunpack ;; +esac +echo "Unpacked:" +find /tmp/tvvlcunpack -name '*.xcframework' -o -name '*.framework' | head -20 diff --git a/apps/ios/scripts/link-tvvlckit.sh b/apps/ios/scripts/link-tvvlckit.sh new file mode 100644 index 0000000..be82461 --- /dev/null +++ b/apps/ios/scripts/link-tvvlckit.sh @@ -0,0 +1,20 @@ +#!/bin/bash +set -euo pipefail +IOS="$HOME/Code/mediacluster/apps/ios" +mkdir -p "$IOS/Vendor" + +XC=$(find /tmp/tvvlcunpack -type d -name 'TVVLCKit.xcframework' | head -1) +if [ -z "$XC" ]; then + XC=$(find /tmp/tvvlcunpack -type d -name '*.xcframework' | head -1) +fi +if [ -z "$XC" ]; then + echo "TVVLCKit.xcframework not found under /tmp/tvvlcunpack — run fetch-tvvlckit.sh first" + find /tmp/tvvlcunpack -maxdepth 4 -type d | head -40 + exit 1 +fi + +rm -rf "$IOS/Vendor/TVVLCKit.xcframework" +cp -R "$XC" "$IOS/Vendor/TVVLCKit.xcframework" +ls -la "$IOS/Vendor/TVVLCKit.xcframework" +echo "Linked to $IOS/Vendor/TVVLCKit.xcframework" +echo "Open VMCTV.xcodeproj — framework path is already Vendor/TVVLCKit.xcframework" diff --git a/apps/master-api/src/settings/iptv.ts b/apps/master-api/src/settings/iptv.ts index 3456aa2..7bcec96 100644 --- a/apps/master-api/src/settings/iptv.ts +++ b/apps/master-api/src/settings/iptv.ts @@ -9,12 +9,15 @@ export type IptvSettingsPublic = { epgXmlUrls: string[]; categoryOrder: string[]; showAlleZenders: boolean; + /** FAAB (of andere) live-events JSON feed; leeg = default FAAB-URL. */ + liveEventsUrl: string; }; type StoredJson = Partial<{ epgXmlUrls: unknown; categoryOrder: unknown; showAlleZenders: unknown; + liveEventsUrl: unknown; }>; type CacheEntry = { at: number; settings: IptvSettingsPublic }; @@ -75,12 +78,28 @@ function normalizeIdList(raw: unknown): string[] { return out; } +const DEFAULT_LIVE_EVENTS_URL = + "http://epg.faabserver.nl/api/export/live-events?horizon_days=3"; + +function normalizeSingleUrl(raw: unknown, fallback = ""): string { + const s = String(raw ?? "").trim(); + if (!s) return fallback; + try { + const parsed = new URL(s); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return fallback; + return s; + } catch { + return fallback; + } +} + function defaultsFromEnv(config: Config): IptvSettingsPublic { const envUrl = config.IPTV_EPG_XML_URL?.trim(); return { epgXmlUrls: envUrl ? [envUrl] : [], categoryOrder: [], showAlleZenders: true, + liveEventsUrl: DEFAULT_LIVE_EVENTS_URL, }; } @@ -90,11 +109,15 @@ function fromStored(stored: StoredJson, config: Config): IptvSettingsPublic { const urls = hasStoredUrls ? normalizeUrlList(stored.epgXmlUrls) : base.epgXmlUrls; + const hasLiveEventsUrl = Object.prototype.hasOwnProperty.call(stored, "liveEventsUrl"); return { epgXmlUrls: urls, categoryOrder: normalizeIdList(stored.categoryOrder), showAlleZenders: typeof stored.showAlleZenders === "boolean" ? stored.showAlleZenders : true, + liveEventsUrl: hasLiveEventsUrl + ? normalizeSingleUrl(stored.liveEventsUrl, "") + : base.liveEventsUrl, }; } @@ -125,6 +148,7 @@ export async function upsertIptvSettings( epgXmlUrls?: unknown; categoryOrder?: unknown; showAlleZenders?: unknown; + liveEventsUrl?: unknown; } ): Promise { const current = await getIptvSettings(config); @@ -141,6 +165,10 @@ export async function upsertIptvSettings( typeof body.showAlleZenders === "boolean" ? body.showAlleZenders : current.showAlleZenders, + liveEventsUrl: + body.liveEventsUrl !== undefined + ? normalizeSingleUrl(body.liveEventsUrl, "") + : current.liveEventsUrl, }; if (body.epgXmlUrls !== undefined) { @@ -154,6 +182,13 @@ export async function upsertIptvSettings( } } + if (body.liveEventsUrl !== undefined) { + const raw = String(body.liveEventsUrl ?? "").trim(); + if (raw && !next.liveEventsUrl) { + throw new AppError("VALIDATION", "Ongeldige live-events URL (http/https)", 400); + } + } + await prisma.integrationSetting.upsert({ where: { id: IPTV_ID }, create: { @@ -169,6 +204,12 @@ export async function upsertIptvSettings( invalidateIptvSettingsCache(); setExternalEpgXmlUrls(next.epgXmlUrls); + try { + const { invalidateScheduleEventsCache } = await import("../viewer/schedule-events"); + invalidateScheduleEventsCache(); + } catch { + /* ignore */ + } return next; } diff --git a/apps/master-api/src/settings/routes.ts b/apps/master-api/src/settings/routes.ts index fc4afce..79a5113 100644 --- a/apps/master-api/src/settings/routes.ts +++ b/apps/master-api/src/settings/routes.ts @@ -95,6 +95,7 @@ export function registerSettingsRoutes( epgXmlUrls?: string[]; categoryOrder?: string[]; showAlleZenders?: boolean; + liveEventsUrl?: string; }; const settings = await upsertIptvSettings(config, body); const categories = await listIptvCategoriesForAdmin(config); diff --git a/apps/master-api/src/viewer/routes.ts b/apps/master-api/src/viewer/routes.ts index cec731f..3e47296 100644 --- a/apps/master-api/src/viewer/routes.ts +++ b/apps/master-api/src/viewer/routes.ts @@ -507,12 +507,17 @@ export function registerViewerRoutes( app.get("/api/v1/client/me", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); const { getViewerIptvStatus } = await import("./iptv-client"); - const iptv = await getViewerIptvStatus(auth.viewerId, config); + const { eventsFeedEnabled } = await import("./schedule-events"); + const [iptv, eventsEnabled] = await Promise.all([ + getViewerIptvStatus(auth.viewerId, config), + eventsFeedEnabled(config), + ]); return { viewer: { id: auth.viewerId, email: auth.email, name: auth.name }, deviceId: auth.deviceId, profile: auth.profile, iptv: { enabled: iptv.enabled }, + events: { enabled: eventsEnabled }, }; }); @@ -842,6 +847,28 @@ export function registerViewerRoutes( }); }); + app.get("/api/v1/client/events", async (request) => { + await viewers.authFromBearer(request.headers.authorization); + const { listScheduleEvents } = await import("./schedule-events"); + return listScheduleEvents(config); + }); + + app.get("/api/v1/client/events/:id", async (request) => { + await viewers.authFromBearer(request.headers.authorization); + const { id } = request.params as { id: string }; + if (!id?.trim()) throw new AppError("INVALID_REQUEST", "Ongeldig event-id", 400); + const { getScheduleEvent } = await import("./schedule-events"); + return { event: await getScheduleEvent(config, decodeURIComponent(id)) }; + }); + + app.get("/api/v1/client/events/:id/play", async (request) => { + await viewers.authFromBearer(request.headers.authorization); + const { id } = request.params as { id: string }; + if (!id?.trim()) throw new AppError("INVALID_REQUEST", "Ongeldig event-id", 400); + const { getScheduleEventPlay } = await import("./schedule-events"); + return getScheduleEventPlay(config, decodeURIComponent(id)); + }); + app.get("/api/v1/client/subtitles/search", async (request) => { await viewers.authFromBearer(request.headers.authorization); const q = request.query as { mediaFileId?: string; languages?: string }; diff --git a/apps/master-api/src/viewer/schedule-events.ts b/apps/master-api/src/viewer/schedule-events.ts new file mode 100644 index 0000000..2d2e844 --- /dev/null +++ b/apps/master-api/src/viewer/schedule-events.ts @@ -0,0 +1,412 @@ +import type { Config } from "../config"; +import { AppError } from "../security/errors"; +import { getIptvSettings } from "../settings/iptv"; + +const CACHE_TTL_MS = 60_000; +const FETCH_TIMEOUT_MS = 20_000; + +export type EventPhase = "live" | "upcoming" | "ended"; + +export type ScheduleEventPublic = { + id: string; + name: string; + description: string | null; + provider: string; + category: string | null; + eventType: string | null; + subtitle: string | null; + channel: string | null; + network: string | null; + isLive: boolean; + phase: EventPhase; + playable: boolean; + status: string | null; + start: string; + stop: string; + startLocal: string | null; + stopLocal: string | null; + imageLandscape: string | null; + imagePortrait: string | null; + logoUrl: string | null; + images: string[]; + audioLanguages: string[]; +}; + +type RawKid = { kid?: unknown; key?: unknown }; +type RawEvent = { + id?: unknown; + name?: unknown; + description?: unknown; + provider?: unknown; + category?: unknown; + is_live?: unknown; + status?: unknown; + start?: unknown; + stop?: unknown; + start_time?: unknown; + end_time?: unknown; + start_local?: unknown; + stop_local?: unknown; + manifest_url?: unknown; + mpd?: unknown; + has_manifest?: unknown; + has_keys?: unknown; + keys?: unknown; + kids?: unknown; + images?: unknown; + epg?: { + title?: unknown; + subtitle?: unknown; + description?: unknown; + category?: unknown; + channel?: unknown; + network?: unknown; + images?: unknown; + }; + metadata?: Record & { + type?: unknown; + images?: unknown; + audio_langs?: unknown; + zender?: unknown; + "originele titel"?: unknown; + }; +}; + +type CacheEntry = { + at: number; + url: string; + events: RawEvent[]; + generatedAt: string | null; +}; + +let cache: CacheEntry | null = null; + +function str(v: unknown): string { + return String(v ?? "").trim(); +} + +function asStringList(v: unknown): string[] { + if (!Array.isArray(v)) return []; + const out: string[] = []; + const seen = new Set(); + for (const item of v) { + const s = str(item); + if (!s || seen.has(s)) continue; + seen.add(s); + out.push(s); + } + return out; +} + +function pickImages(raw: RawEvent): string[] { + const fromEvent = asStringList(raw.images); + const fromEpg = asStringList(raw.epg?.images); + const fromMeta = asStringList(raw.metadata?.images); + const merged = [...fromEvent, ...fromEpg, ...fromMeta]; + const seen = new Set(); + const out: string[] = []; + for (const u of merged) { + if (seen.has(u)) continue; + seen.add(u); + out.push(u); + } + return out; +} + +function scoreLandscape(url: string): number { + const u = url.toLowerCase(); + let score = 0; + if (u.includes("_l.") || u.includes("_l?")) score += 50; + if (u.includes("width=960") && u.includes("height=540")) score += 40; + if (u.includes("width=1280") || u.includes("height=720")) score += 30; + if (u.includes(".jpg") || u.includes(".jpeg") || u.includes(".webp")) score += 5; + if (u.includes("logo")) score -= 40; + if (u.includes("_c.") || u.includes("_c?")) score -= 20; + if (u.includes(".png") && u.includes("logo")) score -= 50; + return score; +} + +function scorePortrait(url: string): number { + const u = url.toLowerCase(); + let score = 0; + if (u.includes("_c.") || u.includes("_c?")) score += 50; + if (u.includes("width=199") || u.includes("height=298")) score += 30; + if (u.includes("logo")) score -= 40; + if (u.includes("_l.")) score -= 10; + return score; +} + +function scoreLogo(url: string): number { + const u = url.toLowerCase(); + let score = 0; + if (u.includes("logo")) score += 60; + if (u.includes(".png")) score += 20; + if (u.includes("cropped")) score += 10; + if (u.includes("_l.") || u.includes("_c.")) score -= 30; + return score; +} + +function bestByScore(urls: string[], scorer: (u: string) => number): string | null { + let best: string | null = null; + let bestScore = -Infinity; + for (const u of urls) { + const s = scorer(u); + if (s > bestScore) { + bestScore = s; + best = u; + } + } + return bestScore > 0 ? best : urls[0] ?? null; +} + +function parseIsoMs(raw: string): number | null { + if (!raw) return null; + const t = Date.parse(raw); + return Number.isFinite(t) ? t : null; +} + +function phaseFor(start: string, stop: string, isLiveFlag: boolean, nowMs: number): EventPhase { + const startMs = parseIsoMs(start); + const stopMs = parseIsoMs(stop); + if (startMs != null && stopMs != null) { + if (nowMs >= startMs && nowMs < stopMs) return "live"; + if (nowMs < startMs) return "upcoming"; + return "ended"; + } + if (isLiveFlag) return "live"; + if (startMs != null && nowMs < startMs) return "upcoming"; + return "ended"; +} + +function extractKids(raw: RawEvent): Array<{ kid: string; key: string }> { + const fromKids = Array.isArray(raw.kids) ? raw.kids : []; + const out: Array<{ kid: string; key: string }> = []; + const seen = new Set(); + for (const item of fromKids) { + const k = item as RawKid; + const kid = str(k.kid).toLowerCase(); + const key = str(k.key).toLowerCase(); + if (!/^[0-9a-f]{32}$/.test(kid) || !/^[0-9a-f]{32}$/.test(key)) continue; + if (seen.has(kid)) continue; + seen.add(kid); + out.push({ kid, key }); + } + if (out.length) return out; + + const keyPairs = Array.isArray(raw.keys) ? raw.keys : []; + for (const item of keyPairs) { + const s = str(item); + const m = s.match(/^([0-9a-fA-F]{32}):([0-9a-fA-F]{32})$/); + if (!m) continue; + const kid = m[1].toLowerCase(); + const key = m[2].toLowerCase(); + if (seen.has(kid)) continue; + seen.add(kid); + out.push({ kid, key }); + } + return out; +} + +function isPlayable(raw: RawEvent): boolean { + const mpd = str(raw.mpd) || str(raw.manifest_url); + if (!mpd) return false; + if (raw.has_manifest === false || raw.has_keys === false) { + // still allow if kids present + } + return extractKids(raw).length > 0; +} + +function toPublic(raw: RawEvent, nowMs: number): ScheduleEventPublic | null { + const id = str(raw.id); + const name = str(raw.name) || str(raw.epg?.title); + if (!id || !name) return null; + + const start = str(raw.start) || str(raw.start_time); + const stop = str(raw.stop) || str(raw.end_time); + if (!start || !stop) return null; + + const images = pickImages(raw); + const meta = raw.metadata ?? {}; + const audioLangs = asStringList(meta.audio_langs); + const isLiveFlag = raw.is_live === true; + const phase = phaseFor(start, stop, isLiveFlag, nowMs); + + return { + id, + name, + description: + str(raw.description) || + str(raw.epg?.description) || + null, + provider: str(raw.provider) || "unknown", + category: str(raw.category) || str(raw.epg?.category) || null, + eventType: str(meta.type) || null, + subtitle: str(meta["originele titel"]) || str(raw.epg?.subtitle) || null, + channel: str(raw.epg?.channel) || str(meta.zender) || null, + network: str(raw.epg?.network) || null, + isLive: phase === "live", + phase, + playable: isPlayable(raw) && phase === "live", + status: str(raw.status) || null, + start, + stop, + startLocal: str(raw.start_local) || null, + stopLocal: str(raw.stop_local) || null, + imageLandscape: bestByScore(images, scoreLandscape), + imagePortrait: bestByScore(images, scorePortrait), + logoUrl: bestByScore(images, scoreLogo), + images, + audioLanguages: audioLangs, + }; +} + +export async function resolveLiveEventsUrl(config: Config): Promise { + const settings = await getIptvSettings(config); + const fromSettings = settings.liveEventsUrl?.trim(); + if (!fromSettings) { + throw new AppError("NOT_CONFIGURED", "Live events feed niet geconfigureerd", 404); + } + return fromSettings; +} + +export async function eventsFeedEnabled(config: Config): Promise { + const settings = await getIptvSettings(config); + return !!settings.liveEventsUrl?.trim(); +} + +async function loadRawEvents(config: Config): Promise { + const url = await resolveLiveEventsUrl(config); + if (cache && cache.url === url && Date.now() - cache.at < CACHE_TTL_MS) { + return cache; + } + + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(), FETCH_TIMEOUT_MS); + try { + const res = await fetch(url, { + signal: ac.signal, + headers: { Accept: "application/json" }, + }); + if (!res.ok) { + throw new AppError( + "UPSTREAM", + `Live events feed HTTP ${res.status}`, + 502 + ); + } + const json = (await res.json()) as { + Meta?: { generated_at?: string }; + events?: unknown; + }; + const events = Array.isArray(json.events) ? (json.events as RawEvent[]) : []; + cache = { + at: Date.now(), + url, + events, + generatedAt: str(json.Meta?.generated_at) || null, + }; + return cache; + } catch (err) { + if (cache && cache.url === url) return cache; + if (err instanceof AppError) throw err; + throw new AppError( + "UPSTREAM", + err instanceof Error ? err.message : "Live events feed onbereikbaar", + 502 + ); + } finally { + clearTimeout(timer); + } +} + +export function invalidateScheduleEventsCache() { + cache = null; +} + +export async function listScheduleEvents(config: Config): Promise<{ + generatedAt: string | null; + events: ScheduleEventPublic[]; + providers: string[]; +}> { + const raw = await loadRawEvents(config); + const nowMs = Date.now(); + const events = raw.events + .map((e) => toPublic(e, nowMs)) + .filter((e): e is ScheduleEventPublic => e != null) + .filter((e) => e.phase !== "ended") + .sort((a, b) => { + if (a.phase === "live" && b.phase !== "live") return -1; + if (b.phase === "live" && a.phase !== "live") return 1; + return Date.parse(a.start) - Date.parse(b.start); + }); + + const providers = [...new Set(events.map((e) => e.provider))].sort((a, b) => + a.localeCompare(b, "nl") + ); + + return { + generatedAt: raw.generatedAt, + events, + providers, + }; +} + +export async function getScheduleEvent( + config: Config, + eventId: string +): Promise { + const id = str(eventId); + const listed = await listScheduleEvents(config); + const hit = listed.events.find((e) => e.id === id); + if (hit) return hit; + + // Ended events still resolvable for detail (no play) + const raw = await loadRawEvents(config); + const nowMs = Date.now(); + const found = raw.events.find((e) => str(e.id) === id); + if (!found) throw new AppError("NOT_FOUND", "Event niet gevonden", 404); + const pub = toPublic(found, nowMs); + if (!pub) throw new AppError("NOT_FOUND", "Event niet gevonden", 404); + return pub; +} + +export async function getScheduleEventPlay(config: Config, eventId: string) { + const id = str(eventId); + const raw = await loadRawEvents(config); + const found = raw.events.find((e) => str(e.id) === id); + if (!found) throw new AppError("NOT_FOUND", "Event niet gevonden", 404); + + const nowMs = Date.now(); + const pub = toPublic(found, nowMs); + if (!pub) throw new AppError("NOT_FOUND", "Event niet gevonden", 404); + if (pub.phase === "ended") { + throw new AppError("GONE", "Dit event is afgelopen", 410); + } + if (pub.phase === "upcoming") { + throw new AppError("NOT_READY", "Dit event is nog niet begonnen", 409); + } + + const mpd = str(found.mpd) || str(found.manifest_url); + const keys = extractKids(found); + if (!mpd || !keys.length) { + throw new AppError( + "NOT_READY", + "Stream start binnenkort", + 409 + ); + } + + return { + eventId: pub.id, + name: pub.name, + logoUrl: pub.logoUrl ?? pub.imagePortrait, + streamUrl: mpd, + format: "dash" as const, + fallbackStreamUrl: null, + fallbackFormat: null, + drm: { + type: "clearkey" as const, + keys, + }, + }; +}