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 0000000..ca93ec2 Binary files /dev/null and b/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Back.png differ 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 0000000..e198a10 Binary files /dev/null and b/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Front.png differ 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 0000000..1c6f430 Binary files /dev/null and b/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Content.imageset/Middle.png differ diff --git a/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Contents.json b/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.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/Middle.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.imagestack/Back.imagestacklayer/Content.imageset/Back.png b/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Back.png new file mode 100644 index 0000000..8425584 Binary files /dev/null and b/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Back.png differ 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 0000000..fe8739e Binary files /dev/null and b/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Back@2x.png differ 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 0000000..63270b7 Binary files /dev/null and b/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Front.png differ 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 0000000..e593365 Binary files /dev/null and b/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Front@2x.png differ diff --git a/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Contents.json b/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.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/Front.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/Middle.imagestacklayer/Content.imageset/Contents.json b/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json new file mode 100644 index 0000000..1e4aad6 --- /dev/null +++ b/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json @@ -0,0 +1,7 @@ +{ + "images" : [ + { "filename" : "Middle.png", "idiom" : "tv", "scale" : "1x" }, + { "filename" : "Middle@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/Middle.imagestacklayer/Content.imageset/Middle.png b/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Middle.png new file mode 100644 index 0000000..3224105 Binary files /dev/null and b/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Middle.png differ 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 0000000..c6d7f53 Binary files /dev/null and b/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Middle@2x.png differ diff --git a/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Contents.json b/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.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/Middle.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/Contents.json b/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Contents.json new file mode 100644 index 0000000..279b3ed --- /dev/null +++ b/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Contents.json @@ -0,0 +1,9 @@ +{ + "assets" : [ + { "filename" : "App Icon - App Store.imagestack", "idiom" : "tv", "role" : "primary-app-icon", "size" : "1280x768" }, + { "filename" : "App Icon.imagestack", "idiom" : "tv", "role" : "primary-app-icon", "size" : "400x240" }, + { "filename" : "Top Shelf Image Wide.imageset", "idiom" : "tv", "role" : "top-shelf-image-wide", "size" : "2320x720" }, + { "filename" : "Top Shelf Image.imageset", "idiom" : "tv", "role" : "top-shelf-image", "size" : "1920x720" } + ], + "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 Wide.imageset/Contents.json b/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Contents.json new file mode 100644 index 0000000..beafbb5 --- /dev/null +++ b/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Contents.json @@ -0,0 +1,7 @@ +{ + "images" : [ + { "filename" : "TopShelfWide.png", "idiom" : "tv", "scale" : "1x" }, + { "filename" : "TopShelfWide@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 Wide.imageset/TopShelfWide.png b/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/TopShelfWide.png new file mode 100644 index 0000000..ae084e7 Binary files /dev/null and b/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/TopShelfWide.png differ 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 0000000..fb68d35 Binary files /dev/null and b/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/TopShelfWide@2x.png differ 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 0000000..db6122a Binary files /dev/null and b/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/TopShelf.png differ 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 0000000..5ec0748 Binary files /dev/null and b/apps/ios/VMCTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/TopShelf@2x.png differ 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, + }, + }; +}