Add Live Events across API/admin/Android TV, mobile ClearKey/DASH fixes, and tvOS app.

Wire FAAB schedule-events into master-api and admin IPTV settings, add Android TV Events UI, fix mobile ClearKey/stream playback (DASH + bump 1.0.23), and scaffold the Apple TV (VMCTV) client with docs/scripts.
This commit is contained in:
Jos Vooges | STH 2026-09-18 23:34:42 +02:00
parent a7e3d03496
commit ac951617cb
79 changed files with 9058 additions and 74 deletions

View file

@ -161,6 +161,9 @@ export default function SettingsPage() {
const [gpErr, setGpErr] = useState<string | null>(null); const [gpErr, setGpErr] = useState<string | null>(null);
const [tvEpgUrlsText, setTvEpgUrlsText] = useState(""); 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 [tvShowAlle, setTvShowAlle] = useState(true);
const [tvCategories, setTvCategories] = useState< const [tvCategories, setTvCategories] = useState<
Array<{ id: string; name: string; channelCount: number }> Array<{ id: string; name: string; channelCount: number }>
@ -254,6 +257,11 @@ export default function SettingsPage() {
if (s) { if (s) {
setTvEpgUrlsText(Array.isArray(s.epgXmlUrls) ? s.epgXmlUrls.join("\n") : ""); setTvEpgUrlsText(Array.isArray(s.epgXmlUrls) ? s.epgXmlUrls.join("\n") : "");
setTvShowAlle(s.showAlleZenders !== false); 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 : []); setTvCategories(Array.isArray(d.categories) ? d.categories : []);
}) })
@ -559,6 +567,7 @@ export default function SettingsPage() {
epgXmlUrls, epgXmlUrls,
categoryOrder: tvCategories.map((c) => c.id), categoryOrder: tvCategories.map((c) => c.id),
showAlleZenders: tvShowAlle, showAlleZenders: tvShowAlle,
liveEventsUrl: tvLiveEventsUrl.trim(),
}), }),
}); });
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
@ -572,6 +581,11 @@ export default function SettingsPage() {
Array.isArray(data.settings.epgXmlUrls) ? data.settings.epgXmlUrls.join("\n") : "" Array.isArray(data.settings.epgXmlUrls) ? data.settings.epgXmlUrls.join("\n") : ""
); );
setTvShowAlle(data.settings.showAlleZenders !== false); 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); if (Array.isArray(data.categories)) setTvCategories(data.categories);
} finally { } finally {
@ -914,6 +928,27 @@ export default function SettingsPage() {
</label> </label>
</div> </div>
<div className="card" style={{ marginBottom: "1rem" }}>
<h3 className="section-title" style={{ fontSize: "1rem", marginBottom: "0.5rem" }}>
Live Events-feed
</h3>
<p className="muted" style={{ marginBottom: "0.75rem" }}>
JSON-export (FAAB) voor de Events-tab in Android TV. Leeg laten schakelt de tab uit.
Default: FAAB live-events met 3 dagen horizon.
</p>
<label style={{ display: "block" }}>
Feed-URL
<input
type="url"
value={tvLiveEventsUrl}
onChange={(e) => setTvLiveEventsUrl(e.target.value)}
placeholder="http://epg.faabserver.nl/api/export/live-events?horizon_days=3"
style={{ width: "100%", fontFamily: "monospace", fontSize: "0.85rem" }}
autoComplete="off"
/>
</label>
</div>
<div className="card"> <div className="card">
<h3 className="section-title" style={{ fontSize: "1rem", marginBottom: "0.5rem" }}> <h3 className="section-title" style={{ fontSize: "1rem", marginBottom: "0.5rem" }}>
Categorie-volgorde Categorie-volgorde

View file

@ -20,8 +20,8 @@ android {
applicationId = "nl.vonas.mediacluster" applicationId = "nl.vonas.mediacluster"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode = 23 versionCode = 24
versionName = "1.0.22" versionName = "1.0.23"
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"") buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
ndk { ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a") abiFilters += listOf("arm64-v8a", "armeabi-v7a")
@ -82,6 +82,7 @@ dependencies {
val media3 = "1.10.1" val media3 = "1.10.1"
implementation("androidx.media3:media3-exoplayer:$media3") implementation("androidx.media3:media3-exoplayer:$media3")
implementation("androidx.media3:media3-exoplayer-hls:$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-ui:$media3")
implementation("androidx.media3:media3-datasource-okhttp:$media3") implementation("androidx.media3:media3-datasource-okhttp:$media3")
implementation("io.github.anilbeesetti:nextlib-media3ext:1.10.1-0.13.0") implementation("io.github.anilbeesetti:nextlib-media3ext:1.10.1-0.13.0")

View file

@ -50,7 +50,7 @@ sealed class CastTarget {
data class CastPlayRequest( data class CastPlayRequest(
val mediaFileId: String? = null, val mediaFileId: String? = null,
val streamId: Int? = null, val streamId: String? = null,
val mediaType: String = "MOVIE", val mediaType: String = "MOVIE",
val movieId: String? = null, val movieId: String? = null,
val episodeId: String? = null, val episodeId: String? = null,

View file

@ -514,7 +514,7 @@ class ApiClient(
val c = arr.getJSONObject(i) val c = arr.getJSONObject(i)
add( add(
IptvChannel( IptvChannel(
streamId = c.getInt("streamId"), streamId = c.optStreamIdString(),
name = c.getString("name"), name = c.getString("name"),
logoUrl = c.optClean("logoUrl"), logoUrl = c.optClean("logoUrl"),
number = c.optInt("number").takeIf { c.has("number") && !c.isNull("number") }, 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) { suspend fun iptvPlayUrl(streamId: String): IptvPlayInfo = withContext(Dispatchers.IO) {
val req = authed("$baseUrl/api/v1/client/iptv/channels/$streamId/play").get().build() val enc = android.net.Uri.encode(streamId)
val req = authed("$baseUrl/api/v1/client/iptv/channels/$enc/play").get().build()
parseObject(execute(req)) { o -> 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( IptvPlayInfo(
streamId = o.getInt("streamId"), streamId = o.optStreamIdString(),
name = o.getString("name"), name = o.getString("name"),
logoUrl = o.optString("logoUrl").ifBlank { null }, logoUrl = o.optString("logoUrl").ifBlank { null },
streamUrl = o.getString("streamUrl"), streamUrl = o.getString("streamUrl"),
format = o.optString("format").ifBlank { "hls" }, format = o.optString("format").ifBlank { "hls" },
fallbackStreamUrl = o.optString("fallbackStreamUrl").ifBlank { null }, fallbackStreamUrl = o.optString("fallbackStreamUrl").ifBlank { null },
fallbackFormat = o.optString("fallbackFormat").ifBlank { null }, fallbackFormat = o.optString("fallbackFormat").ifBlank { null },
drm = drm,
) )
} }
} }
suspend fun iptvEpg(streamId: Int): IptvEpg = withContext(Dispatchers.IO) { suspend fun iptvEpg(streamId: String): IptvEpg = withContext(Dispatchers.IO) {
val req = authed("$baseUrl/api/v1/client/iptv/channels/$streamId/epg").get().build() val enc = android.net.Uri.encode(streamId)
val req = authed("$baseUrl/api/v1/client/iptv/channels/$enc/epg").get().build()
parseObject(execute(req)) { o -> parseObject(execute(req)) { o ->
IptvEpg( IptvEpg(
streamId = o.getInt("streamId"), streamId = o.optStreamIdString(),
channelName = o.getString("channelName"), channelName = o.getString("channelName"),
logoUrl = o.optString("logoUrl").ifBlank { null }, logoUrl = o.optString("logoUrl").ifBlank { null },
now = o.optJSONObject("now")?.toEpgProgram(), now = o.optJSONObject("now")?.toEpgProgram(),
@ -558,13 +577,13 @@ class ApiClient(
/** Vul ontbrekende now-titels aan; [onBatch] voor live UI-updates. */ /** Vul ontbrekende now-titels aan; [onBatch] voor live UI-updates. */
suspend fun fetchMissingNowTitles( suspend fun fetchMissingNowTitles(
channels: List<IptvChannel>, channels: List<IptvChannel>,
onBatch: ((Map<Int, String>) -> Unit)? = null, onBatch: ((Map<String, String>) -> Unit)? = null,
): Map<Int, String> { ): Map<String, String> {
fun clean(title: String?) = title?.takeIf { fun clean(title: String?) = title?.takeIf {
it.isNotBlank() && !it.equals("null", ignoreCase = true) it.isNotBlank() && !it.equals("null", ignoreCase = true)
} }
val result = linkedMapOf<Int, String>() val result = linkedMapOf<String, String>()
channels.forEach { ch -> channels.forEach { ch ->
clean(ch.nowTitle)?.let { result[ch.streamId] = it } clean(ch.nowTitle)?.let { result[ch.streamId] = it }
} }
@ -579,7 +598,7 @@ class ApiClient(
coroutineScope { coroutineScope {
missing.chunked(12).forEach { batch -> missing.chunked(12).forEach { batch ->
val chunk = linkedMapOf<Int, String>() val chunk = linkedMapOf<String, String>()
batch.map { ch -> batch.map { ch ->
async(Dispatchers.IO) { async(Dispatchers.IO) {
runCatching { runCatching {
@ -654,7 +673,7 @@ class ApiClient(
mediaFileId = s?.optClean("mediaFileId"), mediaFileId = s?.optClean("mediaFileId"),
streamId = when { streamId = when {
s == null || !s.has("streamId") || s.isNull("streamId") -> null 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"), 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 { private fun authed(url: String): Request.Builder {
val b = Request.Builder().url(url) val b = Request.Builder().url(url)
val t = token val t = token
@ -776,7 +803,7 @@ data class CastDeviceStatus(
val positionMs: Long, val positionMs: Long,
val durationMs: Long, val durationMs: Long,
val mediaFileId: String?, val mediaFileId: String?,
val streamId: Int?, val streamId: String?,
val mediaType: String?, val mediaType: String?,
) )
@ -878,7 +905,7 @@ data class IptvCategory(
) )
data class IptvChannel( data class IptvChannel(
val streamId: Int, val streamId: String,
val name: String, val name: String,
val logoUrl: String? = null, val logoUrl: String? = null,
val number: Int? = null, val number: Int? = null,
@ -886,14 +913,25 @@ data class IptvChannel(
val nowTitle: String? = null, val nowTitle: String? = null,
) )
data class ClearKeyEntry(
val kid: String,
val key: String,
)
data class DrmInfo(
val type: String,
val keys: List<ClearKeyEntry>,
)
data class IptvPlayInfo( data class IptvPlayInfo(
val streamId: Int, val streamId: String,
val name: String, val name: String,
val logoUrl: String? = null, val logoUrl: String? = null,
val streamUrl: String, val streamUrl: String,
val format: String, val format: String,
val fallbackStreamUrl: String? = null, val fallbackStreamUrl: String? = null,
val fallbackFormat: String? = null, val fallbackFormat: String? = null,
val drm: DrmInfo? = null,
) )
data class EpgProgram( data class EpgProgram(
@ -903,7 +941,7 @@ data class EpgProgram(
) )
data class IptvEpg( data class IptvEpg(
val streamId: Int, val streamId: String,
val channelName: String, val channelName: String,
val logoUrl: String? = null, val logoUrl: String? = null,
val now: EpgProgram? = null, val now: EpgProgram? = null,

View file

@ -2,9 +2,13 @@ package nl.vonas.mediacluster.tv.playback
import android.content.Context import android.content.Context
import android.os.Handler import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.media3.common.AudioAttributes import androidx.media3.common.AudioAttributes
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.common.DrmInitData
import androidx.media3.common.Format
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes import androidx.media3.common.MimeTypes
import androidx.media3.common.TrackSelectionOverride import androidx.media3.common.TrackSelectionOverride
@ -14,17 +18,31 @@ import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.DefaultLoadControl import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.Renderer import androidx.media3.exoplayer.Renderer
import androidx.media3.exoplayer.analytics.PlayerId
import androidx.media3.exoplayer.audio.AudioRendererEventListener import androidx.media3.exoplayer.audio.AudioRendererEventListener
import androidx.media3.exoplayer.audio.AudioSink 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.mediacodec.MediaCodecSelector
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.exoplayer.video.VideoRendererEventListener import androidx.media3.exoplayer.video.VideoRendererEventListener
import androidx.media3.extractor.mp4.PsshAtomUtil
import io.github.anilbeesetti.nextlib.media3ext.ffdecoder.NextRenderersFactory 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_OFF
import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER 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 okhttp3.OkHttpClient
import java.nio.ByteBuffer
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
/** /**
@ -180,12 +198,12 @@ private val liveOkHttp: OkHttpClient by lazy {
.build() .build()
} }
/** Live IPTV: TS-first, kleine buffer, lage latency. */ /** Live IPTV: TS/HLS/DASH. ClearKey-license per mediaId (LocalMediaDrmCallback). */
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
fun buildLiveTvPlayer(context: Context): ExoPlayer { fun buildLiveTvPlayer(context: Context): ExoPlayer {
val appContext = context.applicationContext val appContext = context.applicationContext
val dataSourceFactory = OkHttpDataSource.Factory(liveOkHttp) val dataSourceFactory = OkHttpDataSource.Factory(liveOkHttp)
.setUserAgent("VMC-AndroidTV") .setUserAgent("VMC-Android")
val trackSelector = DefaultTrackSelector(appContext).apply { val trackSelector = DefaultTrackSelector(appContext).apply {
parameters = buildUponParameters() parameters = buildUponParameters()
@ -211,9 +229,15 @@ fun buildLiveTvPlayer(context: Context): ExoPlayer {
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE) .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
.build() .build()
val fallbackDrmProvider = DefaultDrmSessionManagerProvider()
val mediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory)
.setDrmSessionManagerProvider { mediaItem ->
clearKeyDrmSessionManagerFor(mediaItem) ?: fallbackDrmProvider.get(mediaItem)
}
return ExoPlayer.Builder(appContext, renderersFactory) return ExoPlayer.Builder(appContext, renderersFactory)
.setTrackSelector(trackSelector) .setTrackSelector(trackSelector)
.setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory)) .setMediaSourceFactory(mediaSourceFactory)
.setLoadControl(loadControl) .setLoadControl(loadControl)
.setAudioAttributes(audioAttributes, /* handleAudioFocus = */ true) .setAudioAttributes(audioAttributes, /* handleAudioFocus = */ true)
.setHandleAudioBecomingNoisy(true) .setHandleAudioBecomingNoisy(true)
@ -222,10 +246,101 @@ fun buildLiveTvPlayer(context: Context): ExoPlayer {
.also { it.volume = 1f } .also { it.volume = 1f }
} }
/** TS = geen live-offset (laagste latency). HLS-fallback ~2s achter live-rand. */ /** ClearKey license + KIDs per MediaItem.mediaId. */
fun liveMediaItem(url: String, format: String): MediaItem { private data class ClearKeyMaterial(
val isHls = format.equals("hls", ignoreCase = true) || url.contains("m3u8", ignoreCase = true) val licenseJson: String,
val mime = if (isHls) MimeTypes.APPLICATION_M3U8 else MimeTypes.VIDEO_MP2T val keyIds: List<ByteArray>,
)
private val clearKeyLicenses = ConcurrentHashMap<String, ClearKeyMaterial>()
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<ByteArray>,
) : 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) val builder = MediaItem.Builder().setUri(url).setMimeType(mime)
if (isHls) { if (isHls) {
builder.setLiveConfiguration( builder.setLiveConfiguration(
@ -236,9 +351,91 @@ fun liveMediaItem(url: String, format: String): MediaItem {
.build(), .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() return builder.build()
} }
private fun buildClearKeyLicenseJson(keys: List<ClearKeyEntry>): 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) data class PlayerTrack(val groupIndex: Int, val trackIndex: Int, val label: String, val type: Int)
fun ExoPlayer.listTracks(type: Int, embeddedHints: List<nl.vonas.mediacluster.tv.data.EmbeddedTrackInfo> = emptyList()): List<PlayerTrack> { fun ExoPlayer.listTracks(type: Int, embeddedHints: List<nl.vonas.mediacluster.tv.data.EmbeddedTrackInfo> = emptyList()): List<PlayerTrack> {

View file

@ -82,7 +82,7 @@ import nl.vonas.mediacluster.tv.ui.theme.McColors
private data class MenuCategoryCache( private data class MenuCategoryCache(
val channels: List<IptvChannel>, val channels: List<IptvChannel>,
val epgTitles: Map<Int, String>, val epgTitles: Map<String, String>,
val loadedAt: Long, val loadedAt: Long,
) )
@ -92,10 +92,10 @@ private const val MENU_CACHE_TTL_MS = 90_000L
@Composable @Composable
fun LiveTvPlayerScreen( fun LiveTvPlayerScreen(
api: ApiClient, api: ApiClient,
streamId: Int, streamId: String,
categoryId: String?, categoryId: String?,
onCategoryChange: (String) -> Unit, onCategoryChange: (String) -> Unit,
onStreamChange: (Int) -> Unit, onStreamChange: (String) -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
onOpenVmcRemote: (deviceId: String, deviceName: String) -> Unit = { _, _ -> }, onOpenVmcRemote: (deviceId: String, deviceName: String) -> Unit = { _, _ -> },
) { ) {
@ -105,7 +105,7 @@ fun LiveTvPlayerScreen(
val menuScope = rememberCoroutineScope() val menuScope = rememberCoroutineScope()
RememberPlayerImmersiveMode() RememberPlayerImmersiveMode()
var currentStreamId by remember { mutableIntStateOf(streamId) } var currentStreamId by remember { mutableStateOf(streamId) }
var loading by remember { mutableStateOf(true) } var loading by remember { mutableStateOf(true) }
var switching by remember { mutableStateOf(false) } var switching by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
@ -128,17 +128,17 @@ fun LiveTvPlayerScreen(
var lastCategoryShiftAt by remember { mutableLongStateOf(0L) } var lastCategoryShiftAt by remember { mutableLongStateOf(0L) }
var menuLoadGen by remember { mutableIntStateOf(0) } var menuLoadGen by remember { mutableIntStateOf(0) }
val menuCache = remember { mutableStateMapOf<String, MenuCategoryCache>() } val menuCache = remember { mutableStateMapOf<String, MenuCategoryCache>() }
val menuEpgNow = remember { mutableStateMapOf<Int, String>() } val menuEpgNow = remember { mutableStateMapOf<String, String>() }
val menuListState = rememberLazyListState() val menuListState = rememberLazyListState()
val menuChannelFocus = remember { mutableStateMapOf<Int, FocusRequester>() } val menuChannelFocus = remember { mutableStateMapOf<String, FocusRequester>() }
fun startStream(url: String, format: String) { fun startStream(url: String, format: String, drm: nl.vonas.mediacluster.tv.data.DrmInfo? = null) {
player.setMediaItem(liveMediaItem(url, format)) player.setMediaItem(liveMediaItem(url, format, drm))
player.prepare() player.prepare()
player.playWhenReady = true player.playWhenReady = true
} }
suspend fun loadAndPlay(streamIdToPlay: Int, isSwitch: Boolean) { suspend fun loadAndPlay(streamIdToPlay: String, isSwitch: Boolean) {
if (isSwitch) switching = true else loading = true if (isSwitch) switching = true else loading = true
error = null error = null
triedFallback = false triedFallback = false
@ -147,17 +147,24 @@ fun LiveTvPlayerScreen(
channelName = play.name channelName = play.name
logoUrl = play.logoUrl logoUrl = play.logoUrl
streamUrl = play.streamUrl streamUrl = play.streamUrl
val isDash = play.format.equals("dash", ignoreCase = true)
val tsUrl = when { val tsUrl = when {
isDash -> null
play.format.equals("ts", ignoreCase = true) -> play.streamUrl play.format.equals("ts", ignoreCase = true) -> play.streamUrl
play.fallbackFormat?.equals("ts", ignoreCase = true) == true -> play.fallbackStreamUrl play.fallbackFormat?.equals("ts", ignoreCase = true) == true -> play.fallbackStreamUrl
else -> null else -> null
} }
val hlsUrl = when { val hlsUrl = when {
isDash -> null
play.format.equals("hls", ignoreCase = true) -> play.streamUrl play.format.equals("hls", ignoreCase = true) -> play.streamUrl
play.fallbackFormat?.equals("hls", ignoreCase = true) == true -> play.fallbackStreamUrl play.fallbackFormat?.equals("hls", ignoreCase = true) == true -> play.fallbackStreamUrl
else -> null 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 fallbackUrl = hlsUrl ?: play.fallbackStreamUrl
fallbackFormat = (hlsUrl ?: play.fallbackStreamUrl)?.let { fallbackFormat = (hlsUrl ?: play.fallbackStreamUrl)?.let {
if (it.contains("m3u8", true)) "hls" else play.fallbackFormat if (it.contains("m3u8", true)) "hls" else play.fallbackFormat
@ -166,7 +173,7 @@ fun LiveTvPlayerScreen(
} else { } else {
fallbackUrl = play.fallbackStreamUrl fallbackUrl = play.fallbackStreamUrl
fallbackFormat = play.fallbackFormat fallbackFormat = play.fallbackFormat
startStream(play.streamUrl, play.format) startStream(play.streamUrl, play.format, play.drm)
} }
onStreamChange(streamIdToPlay) onStreamChange(streamIdToPlay)
} catch (e: Exception) { } catch (e: Exception) {
@ -202,7 +209,7 @@ fun LiveTvPlayerScreen(
} }
} }
fun applyMenuEpg(titles: Map<Int, String>) { fun applyMenuEpg(titles: Map<String, String>) {
for ((id, title) in titles) { for ((id, title) in titles) {
menuEpgNow[id] = title menuEpgNow[id] = title
} }
@ -219,7 +226,7 @@ fun LiveTvPlayerScreen(
api.iptvChannels(includeEpg = true) api.iptvChannels(includeEpg = true)
} }
if (gen != menuLoadGen) return if (gen != menuLoadGen) return
val titles = linkedMapOf<Int, String>() val titles = linkedMapOf<String, String>()
withEpg.forEach { ch -> ch.nowTitle?.let { titles[ch.streamId] = it } } withEpg.forEach { ch -> ch.nowTitle?.let { titles[ch.streamId] = it } }
applyMenuEpg(titles) applyMenuEpg(titles)
menuCache[key] = MenuCategoryCache(withEpg, titles, System.currentTimeMillis()) menuCache[key] = MenuCategoryCache(withEpg, titles, System.currentTimeMillis())
@ -353,7 +360,7 @@ fun LiveTvPlayerScreen(
onBack() onBack()
} }
fun selectChannel(newStreamId: Int) { fun selectChannel(newStreamId: String) {
if (newStreamId == currentStreamId) return if (newStreamId == currentStreamId) return
currentStreamId = newStreamId currentStreamId = newStreamId
infoOverlay = true infoOverlay = true
@ -628,13 +635,13 @@ private fun LiveChannelSwitcher(
categoryIndex: Int, categoryIndex: Int,
categoryCount: Int, categoryCount: Int,
channels: List<IptvChannel>, channels: List<IptvChannel>,
epgNow: Map<Int, String>, epgNow: Map<String, String>,
currentStreamId: Int, currentStreamId: String,
refreshing: Boolean, refreshing: Boolean,
switching: Boolean, switching: Boolean,
listState: androidx.compose.foundation.lazy.LazyListState, listState: androidx.compose.foundation.lazy.LazyListState,
channelFocus: MutableMap<Int, FocusRequester>, channelFocus: MutableMap<String, FocusRequester>,
onSelect: (Int) -> Unit, onSelect: (String) -> Unit,
onCategoryShift: (Int) -> Unit, onCategoryShift: (Int) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {

View file

@ -114,7 +114,7 @@ fun McNav(
// Blijft behouden als main uit compositie gaat (bijv. live player) // Blijft behouden als main uit compositie gaat (bijv. live player)
var hubTabName by rememberSaveable { mutableStateOf(MainTab.Home.name) } var hubTabName by rememberSaveable { mutableStateOf(MainTab.Home.name) }
var tvCategoryId by rememberSaveable { mutableStateOf<String?>(null) } var tvCategoryId by rememberSaveable { mutableStateOf<String?>(null) }
var tvLastStreamId by rememberSaveable { mutableStateOf<Int?>(null) } var tvLastStreamId by rememberSaveable { mutableStateOf<String?>(null) }
var tvFocusNonce by remember { mutableIntStateOf(0) } var tvFocusNonce by remember { mutableIntStateOf(0) }
val hubTab = remember(hubTabName) { val hubTab = remember(hubTabName) {
MainTab.entries.firstOrNull { it.name == hubTabName } ?: MainTab.Home MainTab.entries.firstOrNull { it.name == hubTabName } ?: MainTab.Home
@ -284,7 +284,7 @@ fun McNav(
onPlayLive = { streamId -> onPlayLive = { streamId ->
tvLastStreamId = streamId tvLastStreamId = streamId
if (hubTabName != MainTab.Tv.name) hubTabName = MainTab.Tv.name if (hubTabName != MainTab.Tv.name) hubTabName = MainTab.Tv.name
nav.navigate("live/$streamId") nav.navigate("live/${android.net.Uri.encode(streamId)}")
}, },
onSwitchProfile = { onSwitchProfile = {
scope.launch { scope.launch {
@ -389,9 +389,11 @@ fun McNav(
} }
composable( composable(
"live/{streamId}", "live/{streamId}",
arguments = listOf(navArgument("streamId") { type = NavType.IntType }), arguments = listOf(navArgument("streamId") { type = NavType.StringType }),
) { entry -> ) { 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) { DisposableEffect(Unit) {
onDispose { tvFocusNonce++ } onDispose { tvFocusNonce++ }
} }
@ -469,8 +471,8 @@ private fun MainHub(
onTabChange: (MainTab) -> Unit, onTabChange: (MainTab) -> Unit,
tvCategoryId: String?, tvCategoryId: String?,
onTvCategoryChange: (String?) -> Unit, onTvCategoryChange: (String?) -> Unit,
tvLastStreamId: Int?, tvLastStreamId: String?,
onTvLastStreamChange: (Int?) -> Unit, onTvLastStreamChange: (String?) -> Unit,
tvFocusNonce: Int, tvFocusNonce: Int,
onTvFocusContent: () -> Unit, onTvFocusContent: () -> Unit,
profileAvatarId: String?, profileAvatarId: String?,
@ -483,7 +485,7 @@ private fun MainHub(
onOpenSeries: (String) -> Unit, onOpenSeries: (String) -> Unit,
onBrowse: (catalogId: String, title: String) -> Unit, onBrowse: (catalogId: String, title: String) -> Unit,
onPlay: (mediaFileId: String, movieId: String?, episodeId: String?, seriesId: String?, type: String, startMs: Long) -> Unit, onPlay: (mediaFileId: String, movieId: String?, episodeId: String?, seriesId: String?, type: String, startMs: Long) -> Unit,
onPlayLive: (Int) -> Unit, onPlayLive: (String) -> Unit,
onSwitchProfile: () -> Unit, onSwitchProfile: () -> Unit,
onManageProfiles: () -> Unit, onManageProfiles: () -> Unit,
onManageDevices: () -> Unit, onManageDevices: () -> Unit,

View file

@ -72,11 +72,11 @@ fun TvScreen(
api: ApiClient, api: ApiClient,
selectedCategoryId: String?, selectedCategoryId: String?,
onCategoryChange: (String?) -> Unit, onCategoryChange: (String?) -> Unit,
lastStreamId: Int? = null, lastStreamId: String? = null,
onLastStreamChange: (Int?) -> Unit = {}, onLastStreamChange: (String?) -> Unit = {},
focusContentNonce: Int = 0, focusContentNonce: Int = 0,
onAtTopBack: () -> Unit, onAtTopBack: () -> Unit,
onPlayChannel: (streamId: Int) -> Unit, onPlayChannel: (streamId: String) -> Unit,
onContentFocused: () -> Unit = {}, onContentFocused: () -> Unit = {},
) { ) {
var categories by remember { mutableStateOf<List<IptvCategory>>(emptyList()) } var categories by remember { mutableStateOf<List<IptvCategory>>(emptyList()) }
@ -85,13 +85,13 @@ fun TvScreen(
var loadingChannels by remember { mutableStateOf(false) } var loadingChannels by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
var selectedEpg by remember { mutableStateOf<IptvEpg?>(null) } var selectedEpg by remember { mutableStateOf<IptvEpg?>(null) }
var selectedStreamId by remember { mutableStateOf<Int?>(null) } var selectedStreamId by remember { mutableStateOf<String?>(null) }
var epgJob by remember { mutableStateOf<Job?>(null) } var epgJob by remember { mutableStateOf<Job?>(null) }
val channelListState = rememberLazyListState() val channelListState = rememberLazyListState()
val categoryRowState = rememberLazyListState() val categoryRowState = rememberLazyListState()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val categoryFocus = remember { mutableStateMapOf<String, FocusRequester>() } val categoryFocus = remember { mutableStateMapOf<String, FocusRequester>() }
val epgNowTitles = remember { mutableStateMapOf<Int, String>() } val epgNowTitles = remember { mutableStateMapOf<String, String>() }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
loading = true loading = true
@ -112,7 +112,7 @@ fun TvScreen(
} }
} }
fun loadEpg(streamId: Int) { fun loadEpg(streamId: String) {
selectedStreamId = streamId selectedStreamId = streamId
onLastStreamChange(streamId) onLastStreamChange(streamId)
epgJob?.cancel() epgJob?.cancel()

View file

@ -487,6 +487,7 @@ class ApiClient(
parseObject(execute(req)) { o -> parseObject(execute(req)) { o ->
ClientMe( ClientMe(
iptvEnabled = o.optJSONObject("iptv")?.optBoolean("enabled") == true, iptvEnabled = o.optJSONObject("iptv")?.optBoolean("enabled") == true,
eventsEnabled = o.optJSONObject("events")?.optBoolean("enabled") == true,
profile = o.optJSONObject("profile")?.toProfile(), 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( suspend fun iptvGuide(
categoryId: String? = null, categoryId: String? = null,
limit: Int = 80, limit: Int = 80,
@ -923,6 +1039,7 @@ data class ShelfPref(
data class ClientMe( data class ClientMe(
val iptvEnabled: Boolean, val iptvEnabled: Boolean,
val eventsEnabled: Boolean = false,
val profile: ViewerProfile? = null, val profile: ViewerProfile? = null,
) )
@ -950,6 +1067,37 @@ data class IptvCategory(
val channelCount: Int, val channelCount: Int,
) )
data class ScheduleEventsPayload(
val generatedAt: String? = null,
val events: List<ScheduleEvent> = emptyList(),
val providers: List<String> = 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<String> = emptyList(),
val audioLanguages: List<String> = emptyList(),
)
data class IptvChannel( data class IptvChannel(
val streamId: String, val streamId: String,
val name: String, val name: String,

View file

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

View file

@ -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<List<ScheduleEvent>>(emptyList()) }
var loading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) }
var providerFilter by remember { mutableStateOf<String?>(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<ScheduleEvent?>(null) }
var error by remember { mutableStateOf<String?>(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<ScheduleEvent>,
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<String>,
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<String, Color> {
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<ScheduleEvent>): List<Pair<String, List<ScheduleEvent>>> {
val today = LocalDate.now(ZoneNl)
val groups = linkedMapOf<String, MutableList<ScheduleEvent>>()
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)
}

View file

@ -341,6 +341,12 @@ fun McNav() {
if (hubTabName != MainTab.Tv.name) hubTabName = MainTab.Tv.name if (hubTabName != MainTab.Tv.name) hubTabName = MainTab.Tv.name
nav.navigate("live/${android.net.Uri.encode(streamId)}") 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 = { onSwitchProfile = {
scope.launch { scope.launch {
runCatching { api.clearProfile() } runCatching { api.clearProfile() }
@ -463,6 +469,35 @@ fun McNav() {
onBack = { nav.popBackStack() }, 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, onBrowse: (catalogId: String, title: String) -> Unit,
onPlay: (mediaFileId: String, movieId: String?, episodeId: String?, seriesId: String?, type: String, startMs: Long) -> Unit, onPlay: (mediaFileId: String, movieId: String?, episodeId: String?, seriesId: String?, type: String, startMs: Long) -> Unit,
onPlayLive: (String) -> Unit, onPlayLive: (String) -> Unit,
onOpenEvent: (String) -> Unit,
onPlayEvent: (String) -> Unit,
onSwitchProfile: () -> Unit, onSwitchProfile: () -> Unit,
onManageProfiles: () -> Unit, onManageProfiles: () -> Unit,
onLogout: () -> Unit, onLogout: () -> Unit,
isOwner: Boolean = false, isOwner: Boolean = false,
) { ) {
var iptvEnabled by remember { mutableStateOf(false) } var iptvEnabled by remember { mutableStateOf(false) }
var eventsEnabled by remember { mutableStateOf(false) }
var navFocused by remember { mutableStateOf(false) } var navFocused by remember { mutableStateOf(false) }
var exitConfirm by remember { mutableStateOf(false) } var exitConfirm by remember { mutableStateOf(false) }
var homeChromeCompact by remember { mutableStateOf(false) } var homeChromeCompact by remember { mutableStateOf(false) }
@ -530,13 +568,18 @@ private fun MainHub(
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
runCatching { shelfPrefs = api.getShelfPrefs().sortedBy { it.sortOrder } } 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 { MainTab.entries.filter {
it != MainTab.Settings && it != MainTab.Search && 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() focusTopNav()
} }
// TV / TV Gids: content scrollt terug, daarna top-nav // 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() focusTopNav()
} }
LaunchedEffect(tvFocusNonce) { LaunchedEffect(tvFocusNonce) {
if (tab == MainTab.Tv || tab == MainTab.TvGuide) { if (tab == MainTab.Tv || tab == MainTab.TvGuide || tab == MainTab.Events) {
navFocused = false navFocused = false
} }
} }
@ -650,7 +693,7 @@ private fun MainHub(
prevTabName = name prevTabName = name
} }
val homeOverlay = tab == MainTab.Home val homeOverlay = tab == MainTab.Home || tab == MainTab.Events
val density = LocalDensity.current val density = LocalDensity.current
var topNavHeightPx by remember { mutableIntStateOf(0) } var topNavHeightPx by remember { mutableIntStateOf(0) }
val topNavBlockHeight = with(density) { val topNavBlockHeight = with(density) {
@ -685,11 +728,12 @@ private fun MainHub(
}, },
onSelect = { onSelect = {
if ((it == MainTab.Tv || it == MainTab.TvGuide) && !iptvEnabled) return@TopNavBar if ((it == MainTab.Tv || it == MainTab.TvGuide) && !iptvEnabled) return@TopNavBar
if (it == MainTab.Events && !eventsEnabled) return@TopNavBar
onTabChange(it) onTabChange(it)
exitConfirm = false exitConfirm = false
homeChromeCompact = false homeChromeCompact = false
when (it) { when (it) {
MainTab.Tv, MainTab.TvGuide -> { MainTab.Tv, MainTab.TvGuide, MainTab.Events -> {
navFocused = false navFocused = false
onTvFocusContent() onTvFocusContent()
} }
@ -824,6 +868,12 @@ private fun MainHub(
}, },
onContentFocused = { navFocused = false }, onContentFocused = { navFocused = false },
) )
MainTab.Events -> EventsScreen(
api = api,
onOpenEvent = onOpenEvent,
onPlayEvent = onPlayEvent,
onContentFocused = { navFocused = false },
)
MainTab.Search -> SearchScreen( MainTab.Search -> SearchScreen(
api = api, api = api,
onOpenMovie = onOpenMovie, onOpenMovie = onOpenMovie,

View file

@ -73,6 +73,7 @@ enum class MainTab(val label: String) {
Series("Series"), Series("Series"),
Tv("TV"), Tv("TV"),
TvGuide("TV Gids"), TvGuide("TV Gids"),
Events("Events"),
Search("Zoeken"), Search("Zoeken"),
Settings("Instellingen"), Settings("Instellingen"),
} }

View file

@ -18,17 +18,26 @@ bash scripts/link-vlckit.sh # kopieert naar Vendor/ + linkt in Xcode
In Xcode: In Xcode:
1. **Signing & Capabilities** → jouw Team 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. ## Apple TV (tvOS)
- HTTP/lokale streams toegestaan (ATS)
- Layout overflow op detailschermen gefixt Apart project (zelfde API, TV-UI met focus/remote):
- Vaste **footer-menu**: Home / Live TV / Zoeken / Account
- Live TV + OpenSubtitles in de speler ```bash
- Premium dark UI 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 ## 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.

View file

@ -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 = "<group>"; };
B10000000000000000000002 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = "<group>"; };
B10000000000000000000003 /* ApiClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiClient.swift; sourceTree = "<group>"; };
B10000000000000000000004 /* SessionStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionStore.swift; sourceTree = "<group>"; };
B10000000000000000000005 /* FavoritesStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesStore.swift; sourceTree = "<group>"; };
B10000000000000000000006 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = "<group>"; };
B10000000000000000000007 /* TvTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TvTheme.swift; sourceTree = "<group>"; };
B10000000000000000000008 /* ProfileAvatars.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileAvatars.swift; sourceTree = "<group>"; };
B10000000000000000000009 /* Components.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Components.swift; sourceTree = "<group>"; };
B1000000000000000000000A /* TopNav.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopNav.swift; sourceTree = "<group>"; };
B1000000000000000000000B /* MainHub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHub.swift; sourceTree = "<group>"; };
B1000000000000000000000C /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; };
B1000000000000000000000D /* BrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseView.swift; sourceTree = "<group>"; };
B1000000000000000000000E /* WatchlistView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchlistView.swift; sourceTree = "<group>"; };
B1000000000000000000000F /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = "<group>"; };
B10000000000000000000010 /* LiveTvView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTvView.swift; sourceTree = "<group>"; };
B10000000000000000000011 /* DetailViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailViews.swift; sourceTree = "<group>"; };
B10000000000000000000012 /* AuthViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthViews.swift; sourceTree = "<group>"; };
B10000000000000000000013 /* ProfilesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilesView.swift; sourceTree = "<group>"; };
B10000000000000000000014 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
B1000000000000000000001D /* ManageProfilesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManageProfilesView.swift; sourceTree = "<group>"; };
B10000000000000000000015 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = "<group>"; };
B10000000000000000000016 /* TVPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVPlayerView.swift; sourceTree = "<group>"; };
B10000000000000000000017 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
B10000000000000000000018 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
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 = "<group>"; };
B1000000000000000000001B /* TVVLCKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = TVVLCKit.xcframework; path = Vendor/TVVLCKit.xcframework; sourceTree = "<group>"; };
/* 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 = "<group>";
};
D10000000000000000000003 /* Products */ = {
isa = PBXGroup;
children = (
B10000000000000000000019 /* VMCTV.app */,
);
name = Products;
sourceTree = "<group>";
};
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 = "<group>";
};
D10000000000000000000004 /* Data */ = {
isa = PBXGroup;
children = (
B10000000000000000000002 /* Models.swift */,
B10000000000000000000003 /* ApiClient.swift */,
B10000000000000000000004 /* SessionStore.swift */,
B10000000000000000000005 /* FavoritesStore.swift */,
B10000000000000000000006 /* AppModel.swift */,
);
path = Data;
sourceTree = "<group>";
};
D10000000000000000000005 /* Theme */ = {
isa = PBXGroup;
children = (
B10000000000000000000007 /* TvTheme.swift */,
B10000000000000000000008 /* ProfileAvatars.swift */,
);
path = Theme;
sourceTree = "<group>";
};
D10000000000000000000006 /* Playback */ = {
isa = PBXGroup;
children = (
B10000000000000000000016 /* TVPlayerView.swift */,
B1000000000000000000001A /* VLCPlayerController.swift */,
);
path = Playback;
sourceTree = "<group>";
};
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 = "<group>";
};
/* 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 */;
}

View file

@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.078",
"green" : "0.035",
"red" : "0.898"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,6 @@
{
"images" : [
{ "filename" : "Back.png", "idiom" : "tv", "scale" : "1x" }
],
"info" : { "author" : "xcode", "version" : 1 }
}

View file

@ -0,0 +1,7 @@
{
"info" : { "author" : "xcode", "version" : 1 },
"properties" : {
"frame-size" : { "height" : 768, "width" : 1280 },
"frame-center" : { "x" : 640, "y" : 384 }
}
}

View file

@ -0,0 +1,8 @@
{
"info" : { "author" : "xcode", "version" : 1 },
"layers" : [
{ "filename" : "Front.imagestacklayer" },
{ "filename" : "Middle.imagestacklayer" },
{ "filename" : "Back.imagestacklayer" }
]
}

View file

@ -0,0 +1,6 @@
{
"images" : [
{ "filename" : "Front.png", "idiom" : "tv", "scale" : "1x" }
],
"info" : { "author" : "xcode", "version" : 1 }
}

View file

@ -0,0 +1,7 @@
{
"info" : { "author" : "xcode", "version" : 1 },
"properties" : {
"frame-size" : { "height" : 768, "width" : 1280 },
"frame-center" : { "x" : 640, "y" : 384 }
}
}

View file

@ -0,0 +1,6 @@
{
"images" : [
{ "filename" : "Middle.png", "idiom" : "tv", "scale" : "1x" }
],
"info" : { "author" : "xcode", "version" : 1 }
}

View file

@ -0,0 +1,7 @@
{
"info" : { "author" : "xcode", "version" : 1 },
"properties" : {
"frame-size" : { "height" : 768, "width" : 1280 },
"frame-center" : { "x" : 640, "y" : 384 }
}
}

View file

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

View file

@ -0,0 +1,7 @@
{
"info" : { "author" : "xcode", "version" : 1 },
"properties" : {
"frame-size" : { "height" : 240, "width" : 400 },
"frame-center" : { "x" : 200, "y" : 120 }
}
}

View file

@ -0,0 +1,8 @@
{
"info" : { "author" : "xcode", "version" : 1 },
"layers" : [
{ "filename" : "Front.imagestacklayer" },
{ "filename" : "Middle.imagestacklayer" },
{ "filename" : "Back.imagestacklayer" }
]
}

View file

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

View file

@ -0,0 +1,7 @@
{
"info" : { "author" : "xcode", "version" : 1 },
"properties" : {
"frame-size" : { "height" : 240, "width" : 400 },
"frame-center" : { "x" : 200, "y" : 120 }
}
}

View file

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

View file

@ -0,0 +1,7 @@
{
"info" : { "author" : "xcode", "version" : 1 },
"properties" : {
"frame-size" : { "height" : 240, "width" : 400 },
"frame-center" : { "x" : 200, "y" : 120 }
}
}

View file

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

View file

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

View file

@ -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<T: Decodable>(_ path: String, authed: Bool = true) async throws -> T {
let data = try await getData(path, authed: authed)
return try JSONDecoder().decode(T.self, from: data)
}
private func post<T: Decodable>(_ path: String, json: [String: Any], authed: Bool = true) async throws -> T {
let data = try await requestData(path, method: "POST", json: json, authed: authed)
if T.self == Empty.self { return Empty() as! T }
if data.isEmpty { throw ApiError.message("Leeg antwoord") }
return try JSONDecoder().decode(T.self, from: data)
}
private func patch<T: Decodable>(_ path: String, json: [String: Any], authed: Bool = true) async throws -> T {
let data = try await requestData(path, method: "PATCH", json: json, authed: authed)
return try JSONDecoder().decode(T.self, from: data)
}
private func requestEmpty(_ path: String, method: String) async throws -> Empty {
_ = try await requestData(path, method: method, json: nil, authed: true)
return Empty()
}
private func getData(_ path: String, authed: Bool) async throws -> Data {
try await requestData(path, method: "GET", json: nil, authed: authed)
}
private func requestData(
_ path: String,
method: String,
json: [String: Any]?,
authed: Bool
) async throws -> Data {
guard let url = URL(string: baseUrl + path) else {
throw ApiError.message("Ongeldige URL")
}
var req = URLRequest(url: url)
req.httpMethod = method
req.setValue("application/json", forHTTPHeaderField: "Accept")
if let json {
req.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
req.httpBody = try JSONSerialization.data(withJSONObject: json)
}
if authed, let token, !token.isEmpty {
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
let (data, response) = try await session.data(for: req)
guard let http = response as? HTTPURLResponse else {
throw ApiError.message("Geen HTTP-antwoord")
}
guard (200..<300).contains(http.statusCode) else {
let msg = (try? JSONDecoder().decode(ApiErrorBody.self, from: data))?.error?.message
?? String(data: data, encoding: .utf8)
?? "HTTP \(http.statusCode)"
throw ApiError.badStatus(http.statusCode, msg)
}
return data
}
}

View file

@ -0,0 +1,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<AnyCancellable>()
init() {
favorites.objectWillChange
.receive(on: RunLoop.main)
.sink { [weak self] _ in self?.objectWillChange.send() }
.store(in: &cancellables)
Task { await bootstrap() }
}
func bootstrap() async {
phase = .boot
bootError = nil
await api.configure(baseUrl: store.apiBase, token: store.refreshToken)
guard store.isAuthed else {
phase = .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
}
}

View file

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

View file

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

View file

@ -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<Int> = []
@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)
}
}

48
apps/ios/VMCTV/Info.plist Normal file
View file

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

View file

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

View file

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

35
apps/ios/VMCTV/README.md Normal file
View file

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

View file

@ -0,0 +1,20 @@
import Foundation
enum ProfileAvatars {
static let all: [(id: String, emoji: String, label: String)] = [
("fox", "🦊", "Vos"),
("panda", "🐼", "Panda"),
("robot", "🤖", "Robot"),
("alien", "👽", "Alien"),
("ninja", "🥷", "Ninja"),
("pirate", "🏴‍☠️", "Piraat"),
("wizard", "🧙", "Tovenaar"),
("dino", "🦕", "Dino"),
("cat", "🐱", "Kat"),
("rocket", "🚀", "Raket"),
]
static func emoji(for id: String?) -> String {
all.first { $0.id == (id ?? "fox") }?.emoji ?? "🙂"
}
}

View file

@ -0,0 +1,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)
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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<String>()
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..<items.count, id: \.self) { i in
Circle()
.fill(i == index % items.count ? Color.white : Color.white.opacity(0.35))
.frame(width: 10, height: 10)
}
}
.padding(.leading, 12)
}
}
}
.padding(.horizontal, 56)
.padding(.bottom, 56)
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(height: heroHeight)
.onAppear {
kenBurns = false
withAnimation(.easeInOut(duration: 14).repeatForever(autoreverses: true)) {
kenBurns = true
}
}
.onReceive(rotate) { _ in
guard items.count > 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()
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -9,12 +9,15 @@ export type IptvSettingsPublic = {
epgXmlUrls: string[]; epgXmlUrls: string[];
categoryOrder: string[]; categoryOrder: string[];
showAlleZenders: boolean; showAlleZenders: boolean;
/** FAAB (of andere) live-events JSON feed; leeg = default FAAB-URL. */
liveEventsUrl: string;
}; };
type StoredJson = Partial<{ type StoredJson = Partial<{
epgXmlUrls: unknown; epgXmlUrls: unknown;
categoryOrder: unknown; categoryOrder: unknown;
showAlleZenders: unknown; showAlleZenders: unknown;
liveEventsUrl: unknown;
}>; }>;
type CacheEntry = { at: number; settings: IptvSettingsPublic }; type CacheEntry = { at: number; settings: IptvSettingsPublic };
@ -75,12 +78,28 @@ function normalizeIdList(raw: unknown): string[] {
return out; 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 { function defaultsFromEnv(config: Config): IptvSettingsPublic {
const envUrl = config.IPTV_EPG_XML_URL?.trim(); const envUrl = config.IPTV_EPG_XML_URL?.trim();
return { return {
epgXmlUrls: envUrl ? [envUrl] : [], epgXmlUrls: envUrl ? [envUrl] : [],
categoryOrder: [], categoryOrder: [],
showAlleZenders: true, showAlleZenders: true,
liveEventsUrl: DEFAULT_LIVE_EVENTS_URL,
}; };
} }
@ -90,11 +109,15 @@ function fromStored(stored: StoredJson, config: Config): IptvSettingsPublic {
const urls = hasStoredUrls const urls = hasStoredUrls
? normalizeUrlList(stored.epgXmlUrls) ? normalizeUrlList(stored.epgXmlUrls)
: base.epgXmlUrls; : base.epgXmlUrls;
const hasLiveEventsUrl = Object.prototype.hasOwnProperty.call(stored, "liveEventsUrl");
return { return {
epgXmlUrls: urls, epgXmlUrls: urls,
categoryOrder: normalizeIdList(stored.categoryOrder), categoryOrder: normalizeIdList(stored.categoryOrder),
showAlleZenders: showAlleZenders:
typeof stored.showAlleZenders === "boolean" ? stored.showAlleZenders : true, typeof stored.showAlleZenders === "boolean" ? stored.showAlleZenders : true,
liveEventsUrl: hasLiveEventsUrl
? normalizeSingleUrl(stored.liveEventsUrl, "")
: base.liveEventsUrl,
}; };
} }
@ -125,6 +148,7 @@ export async function upsertIptvSettings(
epgXmlUrls?: unknown; epgXmlUrls?: unknown;
categoryOrder?: unknown; categoryOrder?: unknown;
showAlleZenders?: unknown; showAlleZenders?: unknown;
liveEventsUrl?: unknown;
} }
): Promise<IptvSettingsPublic> { ): Promise<IptvSettingsPublic> {
const current = await getIptvSettings(config); const current = await getIptvSettings(config);
@ -141,6 +165,10 @@ export async function upsertIptvSettings(
typeof body.showAlleZenders === "boolean" typeof body.showAlleZenders === "boolean"
? body.showAlleZenders ? body.showAlleZenders
: current.showAlleZenders, : current.showAlleZenders,
liveEventsUrl:
body.liveEventsUrl !== undefined
? normalizeSingleUrl(body.liveEventsUrl, "")
: current.liveEventsUrl,
}; };
if (body.epgXmlUrls !== undefined) { 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({ await prisma.integrationSetting.upsert({
where: { id: IPTV_ID }, where: { id: IPTV_ID },
create: { create: {
@ -169,6 +204,12 @@ export async function upsertIptvSettings(
invalidateIptvSettingsCache(); invalidateIptvSettingsCache();
setExternalEpgXmlUrls(next.epgXmlUrls); setExternalEpgXmlUrls(next.epgXmlUrls);
try {
const { invalidateScheduleEventsCache } = await import("../viewer/schedule-events");
invalidateScheduleEventsCache();
} catch {
/* ignore */
}
return next; return next;
} }

View file

@ -95,6 +95,7 @@ export function registerSettingsRoutes(
epgXmlUrls?: string[]; epgXmlUrls?: string[];
categoryOrder?: string[]; categoryOrder?: string[];
showAlleZenders?: boolean; showAlleZenders?: boolean;
liveEventsUrl?: string;
}; };
const settings = await upsertIptvSettings(config, body); const settings = await upsertIptvSettings(config, body);
const categories = await listIptvCategoriesForAdmin(config); const categories = await listIptvCategoriesForAdmin(config);

View file

@ -507,12 +507,17 @@ export function registerViewerRoutes(
app.get("/api/v1/client/me", async (request) => { app.get("/api/v1/client/me", async (request) => {
const auth = await viewers.authFromBearer(request.headers.authorization); const auth = await viewers.authFromBearer(request.headers.authorization);
const { getViewerIptvStatus } = await import("./iptv-client"); 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 { return {
viewer: { id: auth.viewerId, email: auth.email, name: auth.name }, viewer: { id: auth.viewerId, email: auth.email, name: auth.name },
deviceId: auth.deviceId, deviceId: auth.deviceId,
profile: auth.profile, profile: auth.profile,
iptv: { enabled: iptv.enabled }, 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) => { app.get("/api/v1/client/subtitles/search", async (request) => {
await viewers.authFromBearer(request.headers.authorization); await viewers.authFromBearer(request.headers.authorization);
const q = request.query as { mediaFileId?: string; languages?: string }; const q = request.query as { mediaFileId?: string; languages?: string };

View file

@ -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<string, unknown> & {
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<string>();
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<string>();
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<string>();
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<string> {
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<boolean> {
const settings = await getIptvSettings(config);
return !!settings.liveEventsUrl?.trim();
}
async function loadRawEvents(config: Config): Promise<CacheEntry> {
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<ScheduleEventPublic> {
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,
},
};
}