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.
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
418
apps/ios/VMCTV.xcodeproj/project.pbxproj
Normal 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 */;
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 45 KiB |
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{ "filename" : "Back.png", "idiom" : "tv", "scale" : "1x" }
|
||||||
|
],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 },
|
||||||
|
"properties" : {
|
||||||
|
"frame-size" : { "height" : 768, "width" : 1280 },
|
||||||
|
"frame-center" : { "x" : 640, "y" : 384 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 },
|
||||||
|
"layers" : [
|
||||||
|
{ "filename" : "Front.imagestacklayer" },
|
||||||
|
{ "filename" : "Middle.imagestacklayer" },
|
||||||
|
{ "filename" : "Back.imagestacklayer" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{ "filename" : "Front.png", "idiom" : "tv", "scale" : "1x" }
|
||||||
|
],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 }
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 9.8 KiB |
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 },
|
||||||
|
"properties" : {
|
||||||
|
"frame-size" : { "height" : 768, "width" : 1280 },
|
||||||
|
"frame-center" : { "x" : 640, "y" : 384 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{ "filename" : "Middle.png", "idiom" : "tv", "scale" : "1x" }
|
||||||
|
],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 }
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 11 KiB |
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 },
|
||||||
|
"properties" : {
|
||||||
|
"frame-size" : { "height" : 768, "width" : 1280 },
|
||||||
|
"frame-center" : { "x" : 640, "y" : 384 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 },
|
||||||
|
"properties" : {
|
||||||
|
"frame-size" : { "height" : 240, "width" : 400 },
|
||||||
|
"frame-center" : { "x" : 200, "y" : 120 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 },
|
||||||
|
"layers" : [
|
||||||
|
{ "filename" : "Front.imagestacklayer" },
|
||||||
|
{ "filename" : "Middle.imagestacklayer" },
|
||||||
|
{ "filename" : "Back.imagestacklayer" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 },
|
||||||
|
"properties" : {
|
||||||
|
"frame-size" : { "height" : 240, "width" : 400 },
|
||||||
|
"frame-center" : { "x" : 200, "y" : 120 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 },
|
||||||
|
"properties" : {
|
||||||
|
"frame-size" : { "height" : 240, "width" : 400 },
|
||||||
|
"frame-center" : { "x" : 200, "y" : 120 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 238 KiB |
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 237 KiB |
6
apps/ios/VMCTV/Assets.xcassets/Contents.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
394
apps/ios/VMCTV/Data/ApiClient.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
163
apps/ios/VMCTV/Data/AppModel.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
143
apps/ios/VMCTV/Data/FavoritesStore.swift
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
347
apps/ios/VMCTV/Data/Models.swift
Normal 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
|
||||||
|
}
|
||||||
127
apps/ios/VMCTV/Data/SessionStore.swift
Normal 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
|
|
@ -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>
|
||||||
823
apps/ios/VMCTV/Playback/TVPlayerView.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
441
apps/ios/VMCTV/Playback/VLCPlayerController.swift
Normal 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
|
|
@ -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"`
|
||||||
20
apps/ios/VMCTV/Theme/ProfileAvatars.swift
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum ProfileAvatars {
|
||||||
|
static let all: [(id: String, emoji: String, label: String)] = [
|
||||||
|
("fox", "🦊", "Vos"),
|
||||||
|
("panda", "🐼", "Panda"),
|
||||||
|
("robot", "🤖", "Robot"),
|
||||||
|
("alien", "👽", "Alien"),
|
||||||
|
("ninja", "🥷", "Ninja"),
|
||||||
|
("pirate", "🏴☠️", "Piraat"),
|
||||||
|
("wizard", "🧙", "Tovenaar"),
|
||||||
|
("dino", "🦕", "Dino"),
|
||||||
|
("cat", "🐱", "Kat"),
|
||||||
|
("rocket", "🚀", "Raket"),
|
||||||
|
]
|
||||||
|
|
||||||
|
static func emoji(for id: String?) -> String {
|
||||||
|
all.first { $0.id == (id ?? "fox") }?.emoji ?? "🙂"
|
||||||
|
}
|
||||||
|
}
|
||||||
127
apps/ios/VMCTV/Theme/TvTheme.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
347
apps/ios/VMCTV/UI/AuthViews.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
237
apps/ios/VMCTV/UI/BrowseView.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
271
apps/ios/VMCTV/UI/Components.swift
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
502
apps/ios/VMCTV/UI/DetailViews.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
265
apps/ios/VMCTV/UI/HomeView.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
660
apps/ios/VMCTV/UI/LiveTvView.swift
Normal 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
|
||||||
|
}
|
||||||
40
apps/ios/VMCTV/UI/MainHub.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
377
apps/ios/VMCTV/UI/ManageProfilesView.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
232
apps/ios/VMCTV/UI/ProfilesView.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
68
apps/ios/VMCTV/UI/RootView.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
97
apps/ios/VMCTV/UI/SearchView.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
399
apps/ios/VMCTV/UI/SettingsView.swift
Normal 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)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
170
apps/ios/VMCTV/UI/TopNav.swift
Normal 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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
75
apps/ios/VMCTV/UI/WatchlistView.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
apps/ios/VMCTV/VMCTVApp.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
46
apps/ios/scripts/fetch-tvvlckit.sh
Normal 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
|
||||||
20
apps/ios/scripts/link-tvvlckit.sh
Normal 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"
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
412
apps/master-api/src/viewer/schedule-events.ts
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||