Add Apple multiplatform foundation

This commit is contained in:
KhooLy 2026-07-13 03:47:45 +03:00
parent a1ccbefc96
commit 45c45b0ce6
20 changed files with 545 additions and 21 deletions

101
.github/workflows/apple.yml vendored Normal file
View file

@ -0,0 +1,101 @@
name: Apple
on:
pull_request:
paths:
- ".github/workflows/apple.yml"
- "appleApp/**"
- "build-logic/**"
- "core/**"
- "shared/**"
- "gradle/**"
- "gradle.properties"
- "settings.gradle.kts"
- "build.gradle.kts"
push:
branches: [master]
paths:
- ".github/workflows/apple.yml"
- "appleApp/**"
- "build-logic/**"
- "core/**"
- "shared/**"
- "gradle/**"
- "gradle.properties"
- "settings.gradle.kts"
- "build.gradle.kts"
workflow_dispatch:
permissions:
contents: read
jobs:
ios:
name: iOS simulator
runs-on: macos-14
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
- name: Set up XcodeGen
run: |
if ! command -v xcodegen >/dev/null; then
brew install xcodegen
fi
- name: Verify shared iOS code
run: ./gradlew --no-daemon :shared:iosSimulatorArm64Test
- name: Generate Apple host project
working-directory: appleApp
run: xcodegen generate
- name: Build iOS host
run: >-
xcodebuild
-project appleApp/FluxaApple.xcodeproj
-scheme FluxaIos
-sdk iphonesimulator
-destination 'generic/platform=iOS Simulator'
CODE_SIGNING_ALLOWED=NO
build
tvos:
name: tvOS simulator
runs-on: macos-14
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
- name: Set up XcodeGen
run: |
if ! command -v xcodegen >/dev/null; then
brew install xcodegen
fi
- name: Build tvOS shared core
run: ./gradlew --no-daemon :core:linkDebugFrameworkTvosSimulatorArm64
- name: Generate Apple host project
working-directory: appleApp
run: xcodegen generate
- name: Build tvOS host
run: >-
xcodebuild
-project appleApp/FluxaApple.xcodeproj
-scheme FluxaTvos
-sdk appletvsimulator
-destination 'generic/platform=tvOS Simulator'
CODE_SIGNING_ALLOWED=NO
build

View file

@ -2,7 +2,7 @@
<img src="app/src/mobile/res/mipmap-nodpi/ic_launcher.png" alt="Fluxa" width="96" />
# Fluxa
# Fluxa for Android
A fast, native media client for Android phones, tablets, and TV.<br/>
Browse catalogs, track what you watch, and play anything the Stremio addon ecosystem exposes.
@ -10,10 +10,11 @@ Browse catalogs, track what you watch, and play anything the Stremio addon ecosy
[![Contributors][contributors-shield]][contributors-url]
[![Forks][forks-shield]][forks-url]
[![Stars][stars-shield]][stars-url]
[![Issues][issues-shield]][issues-url]
[![Releases][releases-shield]][releases-url]
[![License][license-shield]][license-url]
[![Discord][discord-shield]][discord-url]
[Download](#download) · [Features](#features) · [Building from source](#building-from-source)
[Download](#download) · [Features](#features) · [Building from source](#building-from-source) · [Discord][discord-url]
</div>
@ -38,7 +39,7 @@ The Android shell handles all platform I/O — HTTP, Room, ExoPlayer, audio, not
## Download
Grab the latest build from [Releases](https://github.com/KhooLy/Fluxa/releases/latest). Requires Android 8.0+ (API 26).
Grab the latest build from [Releases](https://github.com/FluxaMedia/fluxa/releases/latest). Requires Android 8.0+ (API 26).
| Variant | Package | Target |
| --- | --- | --- |
@ -56,8 +57,8 @@ Each variant ships per-ABI APKs — pick the one matching your device, or let th
## Building from source
```bash
git clone https://github.com/KhooLy/Fluxa.git
cd Fluxa
git clone https://github.com/FluxaMedia/fluxa.git
cd fluxa
./gradlew :app:assembleMobileDebug
# or for TV
./gradlew :app:assembleTvDebug
@ -99,6 +100,10 @@ Kotlin → executes each effect (OkHttp / Room / audio / ...)
This keeps `fluxa_core` fully portable — the same crate compiles for Android (JNI), desktop (native Rust), and future targets without any platform-specific code inside Rust.
## Platform roadmap
Fluxa for Android ships phone, tablet, and Android TV builds today. The UI is being migrated to Compose Multiplatform so iOS can use the same application interface, while tvOS uses the same shared domain logic with native system integration.
## Stack
[Kotlin](https://kotlinlang.org/) · [Jetpack Compose](https://developer.android.com/compose) · [Rust](https://www.rust-lang.org/) · [AndroidX Media3 / ExoPlayer](https://developer.android.com/media/media3) · [MPV](https://mpv.io/) · [librqbit](https://github.com/ikatson/rqbit) · [UniFFI](https://mozilla.github.io/uniffi-rs/) · [Hilt](https://dagger.dev/hilt/) · [Room](https://developer.android.com/training/data-storage/room) · [OkHttp](https://square.github.io/okhttp/) + [Retrofit](https://square.github.io/retrofit/) · [Coil](https://coil-kt.github.io/coil/)
@ -107,19 +112,25 @@ This keeps `fluxa_core` fully portable — the same crate compiles for Android (
**Legal** — Fluxa is a client-side interface for user-installed Stremio addons. It does not host, serve, or distribute any media content. All streams come from third-party addons chosen by the user. Fluxa is not affiliated with any addon developer, repository, or content provider. Users are responsible for ensuring they have the right to access what they stream.
## Community
Questions, bug reports, feature requests, or just want to hang out — join the [Fluxa Discord][discord-url].
## Related projects
- [Fluxa Desktop](https://github.com/FluxaMedia/fluxa-desktop) — the desktop counterpart to this app
- [Fluxa Desktop](https://github.com/FluxaMedia/fluxa-desktop) — the Windows, macOS, and Linux counterpart
- [fluxa-core](https://github.com/FluxaMedia/fluxa-core) — the shared Rust library powering both
<!-- MARKDOWN LINKS -->
[contributors-shield]: https://img.shields.io/github/contributors/KhooLy/Fluxa.svg?style=for-the-badge
[contributors-url]: https://github.com/KhooLy/Fluxa/graphs/contributors
[forks-shield]: https://img.shields.io/github/forks/KhooLy/Fluxa.svg?style=for-the-badge
[forks-url]: https://github.com/KhooLy/Fluxa/network/members
[stars-shield]: https://img.shields.io/github/stars/KhooLy/Fluxa.svg?style=for-the-badge
[stars-url]: https://github.com/KhooLy/Fluxa/stargazers
[issues-shield]: https://img.shields.io/github/issues/KhooLy/Fluxa.svg?style=for-the-badge
[issues-url]: https://github.com/KhooLy/Fluxa/issues
[license-shield]: https://img.shields.io/github/license/KhooLy/Fluxa.svg?style=for-the-badge
[license-url]: https://github.com/KhooLy/Fluxa/blob/master/LICENSE
[contributors-shield]: https://img.shields.io/github/contributors/FluxaMedia/fluxa.svg?style=for-the-badge
[contributors-url]: https://github.com/FluxaMedia/fluxa/graphs/contributors
[forks-shield]: https://img.shields.io/github/forks/FluxaMedia/fluxa.svg?style=for-the-badge
[forks-url]: https://github.com/FluxaMedia/fluxa/network/members
[stars-shield]: https://img.shields.io/github/stars/FluxaMedia/fluxa.svg?style=for-the-badge
[stars-url]: https://github.com/FluxaMedia/fluxa/stargazers
[releases-shield]: https://img.shields.io/github/v/release/FluxaMedia/fluxa.svg?style=for-the-badge
[releases-url]: https://github.com/FluxaMedia/fluxa/releases/latest
[license-shield]: https://img.shields.io/github/license/FluxaMedia/fluxa.svg?style=for-the-badge
[license-url]: https://github.com/FluxaMedia/fluxa/blob/master/LICENSE
[discord-shield]: https://img.shields.io/badge/Discord-Join-5865F2.svg?style=for-the-badge&logo=discord&logoColor=white
[discord-url]: https://discord.gg/wan9FeDEfe

View file

@ -259,7 +259,8 @@ fun MobileExploreScreen(
) { _, movie ->
val source = discoverSourceFor(movie, resultSources)
MobileExplorePosterCard(
movie = movie,
title = movie.name,
artworkUrl = movie.poster,
onClick = { onMovieClick(movie, source?.transportUrl, source?.type) }
)
}
@ -269,7 +270,7 @@ fun MobileExploreScreen(
}
@Composable
private fun MobileExplorePosterCard(
private fun LegacyMobileExplorePosterCard(
movie: Meta,
onClick: () -> Unit
) {
@ -302,7 +303,7 @@ private fun MobileExplorePosterCard(
}
@Composable
private fun MobileExplorePosterCardSkeleton() {
private fun LegacyMobileExplorePosterCardSkeleton() {
val transition = rememberInfiniteTransition(label = "explore-skeleton")
val alpha by transition.animateFloat(
initialValue = 0.05f,

View file

@ -59,7 +59,7 @@ internal fun collectionFolderLayout(reason: String?, fallbackLayout: String): St
internal const val HOME_CATALOG_LOAD_MORE_THRESHOLD = 5
@Composable
internal fun HomeCatalogCard(
private fun LegacyHomeCatalogCard(
artwork: String?,
width: androidx.compose.ui.unit.Dp,
imageHeight: androidx.compose.ui.unit.Dp,

1
appleApp/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
FluxaApple.xcodeproj

13
appleApp/README.md Normal file
View file

@ -0,0 +1,13 @@
# Apple hosts
`FluxaIos` is a SwiftUI host for the Compose Multiplatform `FluxaShared` framework. `FluxaTvos` is a native SwiftUI host backed by the Kotlin Multiplatform `FluxaCore` framework.
On macOS, install XcodeGen and generate the project:
```bash
cd appleApp
xcodegen generate
open FluxaApple.xcodeproj
```
The Xcode build phase invokes Gradle with the active Xcode SDK and architecture, then embeds the matching framework. Both targets bundle the shared English and Turkish i18n files from `core/src/androidMain/assets/i18n`.

View file

@ -0,0 +1,11 @@
import FluxaShared
import SwiftUI
import UIKit
struct FluxaRootView: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> UIViewController {
FluxaApple.shared.rootViewController()
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
}

View file

@ -0,0 +1,11 @@
import SwiftUI
@main
struct FluxaIosApp: App {
var body: some Scene {
WindowGroup {
FluxaRootView()
.ignoresSafeArea()
}
}
}

29
appleApp/project.yml Normal file
View file

@ -0,0 +1,29 @@
name: FluxaApple
options:
deploymentTarget:
iOS: "16.0"
tvOS: "16.0"
targets:
FluxaIos:
type: application
platform: iOS
sources:
- path: Shared
- path: iOS
resources:
- path: ../core/src/androidMain/assets/i18n
preBuildScripts:
- name: Build FluxaShared
script: ../gradlew :shared:embedAndSignAppleFrameworkForXcode
basedOnDependencyAnalysis: false
FluxaTvos:
type: application
platform: tvOS
sources:
- path: tvOS
resources:
- path: ../core/src/androidMain/assets/i18n
preBuildScripts:
- name: Build FluxaCore
script: ../gradlew :core:embedAndSignAppleFrameworkForXcode
basedOnDependencyAnalysis: false

View file

@ -0,0 +1,11 @@
import FluxaCore
import SwiftUI
@main
struct FluxaTvosApp: App {
var body: some Scene {
WindowGroup {
Text(FluxaTvos.shared.homeTitle())
}
}
}

View file

@ -1,3 +1,5 @@
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget
plugins {
alias(libs.plugins.fluxa.kmp.library)
}
@ -7,6 +9,19 @@ android {
}
kotlin {
tvosArm64()
tvosSimulatorArm64()
targets
.withType<KotlinNativeTarget>()
.matching { it.name.startsWith("tvos") }
.configureEach {
binaries.framework {
baseName = "FluxaCore"
isStatic = true
}
}
sourceSets {
commonMain.dependencies {
implementation(libs.kotlinx.coroutines.core)

View file

@ -8,6 +8,7 @@ actual fun epochMillisNow(): Long = (NSDate().timeIntervalSince1970 * 1000).toLo
internal actual class SimpleLock actual constructor() {
private val lock = NSLock()
actual fun <T> withLock(block: () -> T): T {
lock.lock()
try {

View file

@ -0,0 +1,5 @@
package com.fluxa.app.common
object FluxaTvos {
fun homeTitle(): String = AppStrings.t(null, "nav.home")
}

View file

@ -1,3 +1,5 @@
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget
plugins {
alias(libs.plugins.fluxa.kmp.compose)
}
@ -7,6 +9,17 @@ android {
}
kotlin {
targets
.withType<KotlinNativeTarget>()
.matching { it.name.startsWith("ios") }
.configureEach {
binaries.framework {
baseName = "FluxaShared"
isStatic = true
export(project(":core"))
}
}
sourceSets {
commonMain.dependencies {
implementation(project(":core"))

View file

@ -0,0 +1,164 @@
package com.fluxa.app.ui.catalog
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
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.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import coil3.compose.LocalPlatformContext
import coil3.request.ImageRequest
import coil3.request.crossfade
@Composable
fun HomeCatalogCard(
artwork: String?,
width: Dp,
imageHeight: Dp,
showTitle: Boolean,
title: String,
secondary: String,
showProgressBar: Boolean,
progress: Float,
isUpNext: Boolean,
upNextLabel: String,
topTenRank: Int?,
coverEmoji: String?,
name: String,
onClick: () -> Unit,
onLongClick: (() -> Unit)?
) {
val context = LocalPlatformContext.current
val request = remember(context, artwork) {
ImageRequest.Builder(context)
.data(artwork)
.crossfade(false)
.memoryCacheKey(artwork?.let { "home-catalog:$it" })
.diskCacheKey(artwork)
.build()
}
val clickModifier = if (onLongClick != null) {
Modifier.combinedClickable(interactionSource = null, indication = null, onClick = onClick, onLongClick = onLongClick)
} else {
Modifier.clickable(interactionSource = null, indication = null, onClick = onClick)
}
Column(
modifier = Modifier
.width(width)
.height(imageHeight + if (showTitle) 42.dp else 0.dp)
.then(clickModifier)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(imageHeight)
.background(Color.White.copy(alpha = 0.05f)),
contentAlignment = Alignment.Center
) {
if (!artwork.isNullOrBlank()) {
AsyncImage(
model = request,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else {
androidx.compose.material3.Text(
text = coverEmoji?.takeIf { it.isNotBlank() } ?: name.take(1).uppercase(),
color = Color.White.copy(alpha = if (coverEmoji.isNullOrBlank()) 0.2f else 0.82f),
fontSize = if (coverEmoji.isNullOrBlank()) 42.sp else 36.sp,
fontWeight = FontWeight.Black
)
}
if (showProgressBar) {
Box(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 6.dp)
.height(4.dp)
.clip(RoundedCornerShape(999.dp))
.background(Color.White.copy(alpha = 0.2f))
) {
Box(
modifier = Modifier
.fillMaxWidth(progress)
.fillMaxHeight()
.background(FluxaColors.progressFill)
)
}
}
if (isUpNext) {
androidx.compose.material3.Text(
text = upNextLabel,
color = Color.White,
fontSize = 10.sp,
fontWeight = FontWeight.Black,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp)
.background(Color.Black.copy(alpha = 0.72f))
.padding(horizontal = 8.dp, vertical = 5.dp)
)
}
if (topTenRank != null) {
androidx.compose.material3.Text(
text = topTenRank.toString(),
color = Color.White,
fontSize = 18.sp,
fontWeight = FontWeight.Black,
modifier = Modifier
.align(Alignment.TopStart)
.padding(7.dp)
.background(Color.Black.copy(alpha = 0.66f), RoundedCornerShape(6.dp))
.padding(horizontal = 7.dp, vertical = 3.dp)
)
}
}
if (showTitle) {
androidx.compose.material3.Text(
text = title,
color = Color.White,
fontSize = 12.sp,
lineHeight = 13.sp,
fontWeight = FontWeight.Black,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 4.dp)
)
if (secondary.isNotBlank()) {
androidx.compose.material3.Text(
text = secondary,
color = Color.White.copy(alpha = 0.58f),
fontSize = 10.sp,
lineHeight = 11.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 0.dp)
)
}
}
}
}

View file

@ -0,0 +1,54 @@
package com.fluxa.app.ui.catalog
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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 coil3.compose.AsyncImage
@Composable
fun MobileExplorePosterCard(
title: String,
artworkUrl: String?,
onClick: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
) {
AsyncImage(
model = artworkUrl,
contentDescription = title,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(2f / 3f)
.clip(RoundedCornerShape(10.dp))
.background(Color.White.copy(alpha = 0.06f)),
contentScale = ContentScale.Crop
)
Spacer(Modifier.height(6.dp))
androidx.compose.material3.Text(
text = title,
color = Color.White,
fontSize = 14.sp,
fontWeight = FontWeight.Black,
lineHeight = 16.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}

View file

@ -0,0 +1,53 @@
package com.fluxa.app.ui.catalog
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
@Composable
fun MobileExplorePosterCardSkeleton() {
val transition = rememberInfiniteTransition(label = "explore-skeleton")
val alpha by transition.animateFloat(
initialValue = 0.05f,
targetValue = 0.12f,
animationSpec = infiniteRepeatable(
animation = tween(FluxaDimensions.AnimDuration.ambientColor, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
),
label = "explore-skeleton-alpha"
)
Column(modifier = Modifier.fillMaxWidth()) {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(2f / 3f)
.clip(RoundedCornerShape(10.dp))
.background(Color.White.copy(alpha = alpha))
)
Spacer(Modifier.height(6.dp))
Box(
modifier = Modifier
.fillMaxWidth(0.7f)
.height(14.dp)
.clip(RoundedCornerShape(4.dp))
.background(Color.White.copy(alpha = alpha))
)
}
}

View file

@ -0,0 +1,30 @@
package com.fluxa.app.shared
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.window.ComposeUIViewController
import com.fluxa.app.common.AppStrings
import platform.UIKit.UIViewController
object FluxaApple {
fun rootViewController(): UIViewController = ComposeUIViewController {
FluxaApp()
}
}
@Composable
private fun FluxaApp() {
MaterialTheme {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(AppStrings.t(null, "nav.home"))
}
}
}