├── iosApp ├── Configuration │ └── Config.xcconfig ├── iosApp │ ├── Assets.xcassets │ │ ├── Contents.json │ │ ├── AppIcon.appiconset │ │ │ ├── app-icon-1024.png │ │ │ └── Contents.json │ │ └── AccentColor.colorset │ │ │ └── Contents.json │ ├── Preview Content │ │ └── Preview Assets.xcassets │ │ │ └── Contents.json │ ├── iOSApp.swift │ ├── ContentView.swift │ └── Info.plist └── iosApp.xcodeproj │ └── project.pbxproj ├── gradle ├── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties └── libs.versions.toml ├── composeApp ├── src │ ├── androidMain │ │ ├── res │ │ │ ├── values │ │ │ │ └── strings.xml │ │ │ ├── mipmap-hdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ └── ic_launcher_round.webp │ │ │ ├── mipmap-mdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ └── ic_launcher_round.webp │ │ │ ├── mipmap-xhdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ └── ic_launcher_round.webp │ │ │ ├── mipmap-xxhdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ └── ic_launcher_round.webp │ │ │ ├── mipmap-xxxhdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ └── ic_launcher_round.webp │ │ │ ├── mipmap-anydpi-v26 │ │ │ │ ├── ic_launcher.xml │ │ │ │ └── ic_launcher_round.xml │ │ │ └── drawable │ │ │ │ ├── ic_launcher_background.xml │ │ │ │ └── ic_launcher_foreground.xml │ │ ├── ic_launcher-playstore.png │ │ ├── kotlin │ │ │ └── dev │ │ │ │ └── ech0 │ │ │ │ └── torbox │ │ │ │ └── multiplatform │ │ │ │ ├── Platform.android.kt │ │ │ │ └── MainActivity.kt │ │ └── AndroidManifest.xml │ ├── commonMain │ │ ├── composeResources │ │ │ ├── font │ │ │ │ └── Doto.ttf │ │ │ └── drawable │ │ │ │ ├── tmdb.png │ │ │ │ └── trakt.png │ │ └── kotlin │ │ │ └── dev │ │ │ └── ech0 │ │ │ └── torbox │ │ │ └── multiplatform │ │ │ ├── Platform.kt │ │ │ ├── Greeting.kt │ │ │ ├── Util.kt │ │ │ ├── api │ │ │ ├── Kitsu.kt │ │ │ ├── TMDBApi.kt │ │ │ ├── Trakt.kt │ │ │ └── TorboxAPI.kt │ │ │ ├── ui │ │ │ ├── components │ │ │ │ ├── LoadingScreen.kt │ │ │ │ ├── IconButtonLongClickable.kt │ │ │ │ ├── WatchListItem.kt │ │ │ │ ├── WatchSearchListItemN.kt │ │ │ │ ├── Bars.kt │ │ │ │ ├── Error.kt │ │ │ │ ├── TraktPrompt.kt │ │ │ │ ├── ApiPrompt.kt │ │ │ │ ├── SearchItem.kt │ │ │ │ └── DownloadItem.kt │ │ │ └── pages │ │ │ │ ├── SearchPage.kt │ │ │ │ ├── watch │ │ │ │ ├── WatchSearchPage.kt │ │ │ │ ├── WatchSearchPageN.kt │ │ │ │ └── WatchPage.kt │ │ │ │ └── DownloadsPage.kt │ │ │ └── App.kt │ ├── desktopMain │ │ └── kotlin │ │ │ └── dev │ │ │ └── ech0 │ │ │ └── torbox │ │ │ └── multiplatform │ │ │ ├── Platform.jvm.kt │ │ │ └── main.kt │ └── iosMain │ │ └── kotlin │ │ └── dev │ │ └── ech0 │ │ └── torbox │ │ └── multiplatform │ │ ├── Platform.ios.kt │ │ └── MainViewController.kt ├── lint-baseline.xml └── build.gradle.kts ├── gradle.properties ├── README.md ├── .fleet └── receipt.json ├── .gitignore ├── settings.gradle.kts ├── .github └── FUNDING.yml ├── LICENSE ├── gradlew.bat └── gradlew /iosApp/Configuration/Config.xcconfig: -------------------------------------------------------------------------------- 1 | TEAM_ID= 2 | BUNDLE_ID=dev.ech0.torbox.multiplatform.UTAC 3 | APP_NAME=UTAC -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ech0devv/utac/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | UTAC 3 | -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } -------------------------------------------------------------------------------- /composeApp/src/androidMain/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ech0devv/utac/HEAD/composeApp/src/androidMain/ic_launcher-playstore.png -------------------------------------------------------------------------------- /iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } -------------------------------------------------------------------------------- /composeApp/src/commonMain/composeResources/font/Doto.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ech0devv/utac/HEAD/composeApp/src/commonMain/composeResources/font/Doto.ttf -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ech0devv/utac/HEAD/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.webp -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ech0devv/utac/HEAD/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.webp -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ech0devv/utac/HEAD/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ech0devv/utac/HEAD/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ech0devv/utac/HEAD/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /composeApp/src/commonMain/composeResources/drawable/tmdb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ech0devv/utac/HEAD/composeApp/src/commonMain/composeResources/drawable/tmdb.png -------------------------------------------------------------------------------- /composeApp/src/commonMain/composeResources/drawable/trakt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ech0devv/utac/HEAD/composeApp/src/commonMain/composeResources/drawable/trakt.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ech0devv/utac/HEAD/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ech0devv/utac/HEAD/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ech0devv/utac/HEAD/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ech0devv/utac/HEAD/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ech0devv/utac/HEAD/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ech0devv/utac/HEAD/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /iosApp/iosApp/iOSApp.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | @main 4 | struct iOSApp: App { 5 | var body: some Scene { 6 | WindowGroup { 7 | ContentView() 8 | } 9 | } 10 | } -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/dev/ech0/torbox/multiplatform/Platform.kt: -------------------------------------------------------------------------------- 1 | package dev.ech0.torbox.multiplatform 2 | 3 | interface Platform { 4 | val name: String 5 | } 6 | 7 | expect fun getPlatform(): Platform -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | #Kotlin 2 | kotlin.code.style=official 3 | kotlin.daemon.jvmargs=-Xmx2048M 4 | 5 | #Gradle 6 | org.gradle.jvmargs=-Xmx2048M -Dfile.encoding=UTF-8 7 | 8 | #Android 9 | android.nonTransitiveRClass=true 10 | android.useAndroidX=true -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/dev/ech0/torbox/multiplatform/Greeting.kt: -------------------------------------------------------------------------------- 1 | package dev.ech0.torbox.multiplatform 2 | 3 | class Greeting { 4 | private val platform = getPlatform() 5 | 6 | fun greet(): String { 7 | return "Hello, ${platform.name}!" 8 | } 9 | } -------------------------------------------------------------------------------- /composeApp/src/desktopMain/kotlin/dev/ech0/torbox/multiplatform/Platform.jvm.kt: -------------------------------------------------------------------------------- 1 | package dev.ech0.torbox.multiplatform 2 | 3 | class JVMPlatform: Platform { 4 | override val name: String = "Java ${System.getProperty("java.version")}" 5 | } 6 | 7 | actual fun getPlatform(): Platform = JVMPlatform() -------------------------------------------------------------------------------- /composeApp/src/iosMain/kotlin/dev/ech0/torbox/multiplatform/Platform.ios.kt: -------------------------------------------------------------------------------- 1 | package dev.ech0.torbox.multiplatform 2 | 3 | import platform.UIKit.UIDevice 4 | 5 | class IOSPlatform: Platform { 6 | override val name: String = "iOS" 7 | } 8 | 9 | actual fun getPlatform(): Platform = IOSPlatform() -------------------------------------------------------------------------------- /composeApp/src/androidMain/kotlin/dev/ech0/torbox/multiplatform/Platform.android.kt: -------------------------------------------------------------------------------- 1 | package dev.ech0.torbox.multiplatform 2 | 3 | import android.os.Build 4 | 5 | class AndroidPlatform : Platform { 6 | override val name: String = "Android" 7 | } 8 | 9 | actual fun getPlatform(): Platform = AndroidPlatform() -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "app-icon-1024.png", 5 | "idiom" : "universal", 6 | "platform" : "ios", 7 | "size" : "1024x1024" 8 | } 9 | ], 10 | "info" : { 11 | "author" : "xcode", 12 | "version" : 1 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Fri Jan 24 14:10:18 EST 2025 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip 5 | networkTimeout=10000 6 | validateDistributionUrl=true 7 | zipStoreBase=GRADLE_USER_HOME 8 | zipStorePath=wrapper/dists 9 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # UTAC 2 | ### UTAC, now with multiplatform support! 3 | 4 | ## How to build: 5 | - Android and Desktop 6 | - Open in android studio 7 | - Rename key.properties.example to key.properties and fill in values 8 | - Build like normal 9 | - iOS (Requires a Mac with macOS 14.5 or later) 10 | - Perform above steps, then: 11 | - Open iosApp/ in XCode at least once 12 | - Go to iosApp/ in a terminal and run: 13 | - `xcodebuild CODE_SIGNING_REQUIRED=no ENTITLEMENTS_REQUIRED=no CODE_SIGN_IDENTITY="" build` -------------------------------------------------------------------------------- /iosApp/iosApp/ContentView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import SwiftUI 3 | import ComposeApp 4 | 5 | struct ComposeView: UIViewControllerRepresentable { 6 | func makeUIViewController(context: Context) -> UIViewController { 7 | MainViewControllerKt.MainViewController() 8 | } 9 | 10 | func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} 11 | } 12 | 13 | struct ContentView: View { 14 | var body: some View { 15 | ComposeView() 16 | .ignoresSafeArea() // Compose has own keyboard handler 17 | } 18 | } 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /.fleet/receipt.json: -------------------------------------------------------------------------------- 1 | // Project generated by Kotlin Multiplatform Wizard 2 | { 3 | "spec": { 4 | "template_id": "kmt", 5 | "targets": { 6 | "android": { 7 | "ui": [ 8 | "compose" 9 | ] 10 | }, 11 | "ios": { 12 | "ui": [ 13 | "compose" 14 | ] 15 | }, 16 | "desktop": { 17 | "ui": [ 18 | "compose" 19 | ] 20 | } 21 | } 22 | }, 23 | "timestamp": "2025-01-23T15:40:29.132022722Z" 24 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .kotlin 3 | .gradle 4 | **/build/ 5 | xcuserdata 6 | !src/**/build/ 7 | local.properties 8 | .idea 9 | .DS_Store 10 | captures 11 | .externalNativeBuild 12 | .cxx 13 | *.xcodeproj/* 14 | !*.xcodeproj/project.pbxproj 15 | !*.xcodeproj/xcshareddata/ 16 | !*.xcodeproj/project.xcworkspace/ 17 | !*.xcworkspace/contents.xcworkspacedata 18 | **/xcshareddata/WorkspaceSettings.xcsettings 19 | *.iml 20 | .gradle 21 | /local.properties 22 | /.idea/caches 23 | /.idea/libraries 24 | /.idea/modules.xml 25 | /.idea/workspace.xml 26 | /.idea/navEditor.xml 27 | /.idea/assetWizardSettings.xml 28 | .DS_Store 29 | /build 30 | /captures 31 | .externalNativeBuild 32 | .cxx 33 | local.properties 34 | key.properties -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/dev/ech0/torbox/multiplatform/Util.kt: -------------------------------------------------------------------------------- 1 | package dev.ech0.torbox.multiplatform 2 | 3 | import kotlinx.serialization.json.JsonObject 4 | import kotlin.math.round 5 | import kotlin.math.roundToLong 6 | 7 | fun roundToHundredth(number: Double): Double{ 8 | return round(number * 100.0) / 100.0 9 | } 10 | 11 | fun formatFileSize(bytes: Long): String { 12 | val kilobyte = 1024.0 13 | val megabyte = kilobyte * 1024 14 | val gigabyte = megabyte * 1024 15 | 16 | return when { 17 | bytes < kilobyte -> "$bytes B" 18 | bytes < megabyte -> "${roundToHundredth(bytes/kilobyte)} KB" 19 | bytes < gigabyte -> "${roundToHundredth(bytes/megabyte)} MB" 20 | else -> "${roundToHundredth(bytes/gigabyte)} GB" 21 | } 22 | } -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "UTAC" 2 | enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") 3 | 4 | pluginManagement { 5 | repositories { 6 | google { 7 | mavenContent { 8 | includeGroupAndSubgroups("androidx") 9 | includeGroupAndSubgroups("com.android") 10 | includeGroupAndSubgroups("com.google") 11 | } 12 | } 13 | mavenCentral() 14 | gradlePluginPortal() 15 | } 16 | } 17 | 18 | dependencyResolutionManagement { 19 | repositories { 20 | google { 21 | mavenContent { 22 | includeGroupAndSubgroups("androidx") 23 | includeGroupAndSubgroups("com.android") 24 | includeGroupAndSubgroups("com.google") 25 | } 26 | } 27 | mavenCentral() 28 | } 29 | } 30 | 31 | include(":composeApp") -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: ech0x0 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username 14 | thanks_dev: # Replace with a single thanks.dev username 15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 16 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/dev/ech0/torbox/multiplatform/api/Kitsu.kt: -------------------------------------------------------------------------------- 1 | package dev.ech0.torbox.multiplatform.api 2 | 3 | import com.russhwolf.settings.Settings 4 | import dev.ech0.torbox.multiplatform.BuildConfig 5 | import io.ktor.client.HttpClient 6 | import io.ktor.client.request.* 7 | import io.ktor.client.statement.bodyAsText 8 | import io.ktor.http.* 9 | import kotlinx.serialization.json.* 10 | 11 | class KitsuAPI() { 12 | private val base = " https://kitsu.io/api/edge/" 13 | private val ktor = HttpClient(){ 14 | } 15 | init { 16 | } 17 | suspend fun search(query: String): JsonArray{ 18 | val response = ktor.get(base + "search/multi?query=${query.encodeURLPath()}&include_adult=${Settings().getBoolean("adultContent", false)}"){ 19 | headers { 20 | append(HttpHeaders.Accept, "application/json") 21 | } 22 | } 23 | val json = Json.parseToJsonElement(response.bodyAsText()).jsonObject 24 | return json["results"]!!.jsonArray 25 | } 26 | } -------------------------------------------------------------------------------- /composeApp/src/iosMain/kotlin/dev/ech0/torbox/multiplatform/MainViewController.kt: -------------------------------------------------------------------------------- 1 | package dev.ech0.torbox.multiplatform 2 | 3 | import androidx.compose.material3.ColorScheme 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.rememberCoroutineScope 6 | import androidx.compose.ui.window.ComposeUIViewController 7 | import kotlinx.coroutines.launch 8 | import platform.Foundation.NSURL 9 | import platform.UIKit.UIApplication 10 | 11 | @Composable 12 | actual fun PlayVideo(videoUrl: String) { 13 | val nsUrl = NSURL.URLWithString(videoUrl) 14 | val snackbarHostState = LocalSnackbarHostState.current 15 | val scope = rememberCoroutineScope() 16 | if(nsUrl != null) { 17 | UIApplication.sharedApplication.openURL(nsUrl) 18 | }else{ 19 | scope.launch { 20 | snackbarHostState.showSnackbar("Something went wrong") 21 | } 22 | } 23 | } 24 | 25 | fun MainViewController() = ComposeUIViewController { App() } 26 | @Composable 27 | actual fun GetDynamicScheme(): ColorScheme? { 28 | return null 29 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 ech0 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /composeApp/src/desktopMain/kotlin/dev/ech0/torbox/multiplatform/main.kt: -------------------------------------------------------------------------------- 1 | package dev.ech0.torbox.multiplatform 2 | 3 | import androidx.compose.material3.ColorScheme 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.rememberCoroutineScope 6 | import androidx.compose.ui.window.Window 7 | import androidx.compose.ui.window.application 8 | import kotlinx.coroutines.launch 9 | import java.awt.Desktop 10 | 11 | fun main() = application { 12 | Window( 13 | onCloseRequest = ::exitApplication, 14 | title = "UTAC", 15 | ) { 16 | App() 17 | } 18 | } 19 | @Composable 20 | actual fun PlayVideo(videoUrl: String) { 21 | val desktop = Desktop.getDesktop() 22 | val snackbarHostState = LocalSnackbarHostState.current 23 | val scope = rememberCoroutineScope() 24 | if(desktop.isSupported(Desktop.Action.BROWSE)) { 25 | desktop.browse(java.net.URI.create(videoUrl)) 26 | }else{ 27 | scope.launch{ 28 | snackbarHostState.showSnackbar("No video player found :(") 29 | } 30 | } 31 | } 32 | @Composable 33 | actual fun GetDynamicScheme(): ColorScheme? { 34 | return null 35 | } -------------------------------------------------------------------------------- /composeApp/src/androidMain/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /iosApp/iosApp/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | CADisableMinimumFrameDurationOnPhone 24 | 25 | UIApplicationSceneManifest 26 | 27 | UIApplicationSupportsMultipleScenes 28 | 29 | 30 | UILaunchScreen 31 | 32 | UIRequiredDeviceCapabilities 33 | 34 | armv7 35 | 36 | UISupportedInterfaceOrientations 37 | 38 | UIInterfaceOrientationPortrait 39 | UIInterfaceOrientationLandscapeLeft 40 | UIInterfaceOrientationLandscapeRight 41 | 42 | UISupportedInterfaceOrientations~ipad 43 | 44 | UIInterfaceOrientationPortrait 45 | UIInterfaceOrientationPortraitUpsideDown 46 | UIInterfaceOrientationLandscapeLeft 47 | UIInterfaceOrientationLandscapeRight 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 6 | 13 | 20 | 27 | 34 | 41 | 42 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/dev/ech0/torbox/multiplatform/ui/components/LoadingScreen.kt: -------------------------------------------------------------------------------- 1 | package dev.ech0.torbox.multiplatform.ui.components 2 | 3 | 4 | import androidx.compose.foundation.clickable 5 | import androidx.compose.foundation.gestures.detectTapGestures 6 | import androidx.compose.foundation.layout.Box 7 | import androidx.compose.foundation.layout.Column 8 | import androidx.compose.foundation.layout.fillMaxSize 9 | import androidx.compose.foundation.layout.fillMaxWidth 10 | import androidx.compose.foundation.layout.padding 11 | import androidx.compose.foundation.layout.size 12 | import androidx.compose.foundation.layout.wrapContentHeight 13 | import androidx.compose.foundation.layout.wrapContentWidth 14 | import androidx.compose.material3.CircularProgressIndicator 15 | import androidx.compose.material3.MaterialTheme 16 | import androidx.compose.material3.Text 17 | import androidx.compose.runtime.Composable 18 | import androidx.compose.ui.Alignment 19 | import androidx.compose.ui.Modifier 20 | import androidx.compose.ui.input.pointer.pointerInput 21 | import androidx.compose.ui.text.style.TextAlign 22 | import androidx.compose.ui.unit.dp 23 | 24 | @Composable 25 | fun LoadingScreen() { 26 | LoadingScreen("Just a sec...") 27 | } 28 | @Composable 29 | fun LoadingScreen(value: String) { 30 | Box( 31 | modifier = Modifier 32 | .fillMaxSize() 33 | .pointerInput(Unit) { detectTapGestures {} } 34 | ) {} 35 | Column( 36 | modifier = Modifier 37 | .fillMaxSize() 38 | .wrapContentHeight(align = Alignment.CenterVertically) 39 | .wrapContentWidth(align = Alignment.CenterHorizontally), 40 | horizontalAlignment = Alignment.CenterHorizontally 41 | 42 | 43 | ) { 44 | CircularProgressIndicator( 45 | modifier = Modifier 46 | .size(40.dp), 47 | strokeWidth = 5.dp 48 | ) 49 | Text( 50 | value, 51 | style = MaterialTheme.typography.bodyMedium, 52 | modifier = Modifier.padding(top = 10.dp) 53 | ) 54 | } 55 | } -------------------------------------------------------------------------------- /composeApp/src/androidMain/kotlin/dev/ech0/torbox/multiplatform/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package dev.ech0.torbox.multiplatform 2 | 3 | import android.content.ActivityNotFoundException 4 | import android.content.Intent 5 | import android.net.Uri 6 | import android.os.Build 7 | import android.os.Bundle 8 | import android.widget.Toast 9 | import androidx.activity.ComponentActivity 10 | import androidx.activity.compose.setContent 11 | import androidx.compose.material3.ColorScheme 12 | import androidx.compose.material3.dynamicDarkColorScheme 13 | import androidx.compose.material3.dynamicLightColorScheme 14 | import androidx.compose.runtime.Composable 15 | import androidx.compose.runtime.rememberCoroutineScope 16 | import androidx.compose.ui.platform.LocalContext 17 | import androidx.compose.ui.tooling.preview.Preview 18 | import com.russhwolf.settings.Settings 19 | import kotlinx.coroutines.launch 20 | 21 | class MainActivity : ComponentActivity() { 22 | override fun onCreate(savedInstanceState: Bundle?) { 23 | super.onCreate(savedInstanceState) 24 | 25 | setContent { 26 | App() 27 | } 28 | } 29 | } 30 | @Composable 31 | actual fun PlayVideo(videoUrl: String) { 32 | val context = LocalContext.current 33 | val scope = rememberCoroutineScope() 34 | val localSnackbarHostState = LocalSnackbarHostState.current 35 | try { 36 | val playVideo = Intent(Intent.ACTION_VIEW) 37 | playVideo.setDataAndType( 38 | Uri.parse(videoUrl), "video/x-unknown" 39 | ) 40 | context.startActivity(playVideo) 41 | } catch (e: ActivityNotFoundException) { 42 | scope.launch { 43 | localSnackbarHostState.showSnackbar("No video player found :(") 44 | } 45 | } 46 | } 47 | @Composable 48 | actual fun GetDynamicScheme(): ColorScheme? { 49 | if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.S){ 50 | return when { 51 | Settings().getBoolean("dark", true) -> dynamicDarkColorScheme(LocalContext.current) 52 | !Settings().getBoolean("dark", true) -> dynamicLightColorScheme(LocalContext.current) 53 | else -> null 54 | } 55 | } 56 | return null 57 | } -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/dev/ech0/torbox/multiplatform/ui/components/IconButtonLongClickable.kt: -------------------------------------------------------------------------------- 1 | package dev.ech0.torbox.multiplatform.ui.components 2 | 3 | import androidx.compose.foundation.ExperimentalFoundationApi 4 | import androidx.compose.foundation.background 5 | import androidx.compose.foundation.combinedClickable 6 | import androidx.compose.foundation.interaction.MutableInteractionSource 7 | import androidx.compose.foundation.layout.Box 8 | import androidx.compose.foundation.layout.size 9 | import androidx.compose.material3.* 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.runtime.CompositionLocalProvider 12 | import androidx.compose.runtime.remember 13 | import androidx.compose.ui.Alignment 14 | import androidx.compose.ui.Modifier 15 | import androidx.compose.ui.draw.clip 16 | import androidx.compose.ui.graphics.Shape 17 | import androidx.compose.ui.semantics.Role 18 | 19 | @OptIn(ExperimentalFoundationApi::class) 20 | @Composable 21 | fun IconButtonLongClickable( 22 | onClick: () -> Unit, 23 | onLongClick: () -> Unit, 24 | modifier: Modifier = Modifier, 25 | enabled: Boolean = true, 26 | colors: IconButtonColors = IconButtonDefaults.iconButtonColors(), 27 | interactionSource: MutableInteractionSource? = null, 28 | content: @Composable () -> Unit, 29 | ) { 30 | @Suppress("NAME_SHADOWING") 31 | val interactionSource = interactionSource ?: remember { MutableInteractionSource() } 32 | Box( 33 | modifier = 34 | modifier 35 | .minimumInteractiveComponentSize() 36 | .clip(IconButtonDefaults.filledShape) 37 | .background(color = if(enabled) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.onSurface, shape = IconButtonDefaults.filledShape) 38 | .combinedClickable( 39 | onClick = onClick, 40 | onLongClick = onLongClick, 41 | enabled = enabled, 42 | role = Role.Button, 43 | interactionSource = interactionSource, 44 | indication = ripple() 45 | ), 46 | contentAlignment = Alignment.Center 47 | ) { 48 | val contentColor = if(enabled) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.onSurface 49 | CompositionLocalProvider(LocalContentColor provides contentColor, content = content) 50 | } 51 | } -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/dev/ech0/torbox/multiplatform/api/TMDBApi.kt: -------------------------------------------------------------------------------- 1 | package dev.ech0.torbox.multiplatform.api 2 | 3 | import com.russhwolf.settings.Settings 4 | import dev.ech0.torbox.multiplatform.BuildConfig 5 | import io.ktor.client.HttpClient 6 | import io.ktor.client.request.* 7 | import io.ktor.client.statement.bodyAsText 8 | import io.ktor.http.* 9 | import kotlinx.serialization.json.* 10 | 11 | lateinit var tmdbApi: TMDBApi 12 | 13 | class TMDBApi() { 14 | private val base = "https://api.themoviedb.org/3/" 15 | private var token = BuildConfig.TMDB_KEY 16 | private val ktor = HttpClient(){ 17 | } 18 | init { 19 | } 20 | suspend fun search(query: String): JsonArray{ 21 | val response = ktor.get(base + "search/multi?query=${query.encodeURLPath()}&include_adult=${Settings().getBoolean("adultContent", false)}"){ 22 | headers { 23 | append(HttpHeaders.Authorization, "Bearer $token") 24 | append(HttpHeaders.Accept, "application/json") 25 | } 26 | } 27 | val json = Json.parseToJsonElement(response.bodyAsText()).jsonObject 28 | return json["results"]!!.jsonArray 29 | } 30 | suspend fun getTvDetails(id: Int): JsonObject{ 31 | val response = ktor.get(base + "tv/$id?append_to_response=external_ids,content_ratings"){ 32 | headers { 33 | append(HttpHeaders.Authorization, "Bearer $token") 34 | append(HttpHeaders.Accept, "application/json") 35 | } 36 | } 37 | val json = Json.parseToJsonElement(response.bodyAsText()).jsonObject 38 | return json 39 | } 40 | suspend fun getSeasonDetails(id: Int, season: Int): JsonObject{ 41 | val response = ktor.get(base + "tv/$id/season/$season"){ 42 | headers { 43 | append(HttpHeaders.Authorization, "Bearer $token") 44 | append(HttpHeaders.Accept, "application/json") 45 | } 46 | } 47 | val json = Json.parseToJsonElement(response.bodyAsText()).jsonObject 48 | return json 49 | } 50 | fun imageHelper(path: String): String { 51 | return "https://image.tmdb.org/t/p/original$path" 52 | } 53 | suspend fun getMovieDetails(id: Int): JsonObject{ 54 | val response = ktor.get(base + "movie/$id?append_to_response=external_ids"){ 55 | headers { 56 | append(HttpHeaders.Authorization, "Bearer $token") 57 | append(HttpHeaders.Accept, "application/json") 58 | } 59 | } 60 | val json = Json.parseToJsonElement(response.bodyAsText()).jsonObject 61 | return json 62 | } 63 | } -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/dev/ech0/torbox/multiplatform/ui/components/WatchListItem.kt: -------------------------------------------------------------------------------- 1 | package dev.ech0.torbox.multiplatform.ui.components 2 | 3 | import androidx.compose.foundation.ExperimentalFoundationApi 4 | import androidx.compose.foundation.combinedClickable 5 | import androidx.compose.foundation.layout.* 6 | import androidx.compose.foundation.shape.RoundedCornerShape 7 | import androidx.compose.material3.HorizontalDivider 8 | import androidx.compose.material3.MaterialTheme 9 | import androidx.compose.material3.Text 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.Alignment 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.draw.clip 14 | import androidx.compose.ui.layout.ContentScale 15 | import androidx.compose.ui.text.font.FontWeight 16 | import androidx.compose.ui.text.style.TextOverflow 17 | import androidx.compose.ui.unit.dp 18 | import coil3.compose.AsyncImage 19 | import dev.ech0.torbox.multiplatform.api.tmdbApi 20 | import kotlinx.serialization.json.JsonObject 21 | import kotlinx.serialization.json.jsonPrimitive 22 | 23 | @OptIn(ExperimentalFoundationApi::class) 24 | @Composable 25 | fun WatchListItem(meta: JsonObject, setWatchShowPage: (String) -> Unit, setWatchShowJson: (JsonObject) -> Unit) { 26 | Row( 27 | modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp).height(200.dp) 28 | .combinedClickable(onClick = { 29 | setWatchShowJson(meta) 30 | setWatchShowPage(meta["media_type"]!!.jsonPrimitive.content) 31 | }), verticalAlignment = Alignment.CenterVertically 32 | ) { 33 | if (meta.contains("poster_path")) { 34 | AsyncImage( 35 | model = tmdbApi.imageHelper(meta["poster_path"]!!.jsonPrimitive.content), 36 | contentDescription = null, 37 | modifier = Modifier.padding(8.dp).padding(end = 16.dp).clip(RoundedCornerShape(12.dp)).fillMaxHeight() 38 | .width(125.dp), 39 | contentScale = ContentScale.FillHeight 40 | ) 41 | } 42 | Column( 43 | verticalArrangement = Arrangement.Center 44 | ) { 45 | Text( 46 | (meta["name"] ?: meta["title"] ?: "").toString(), 47 | style = MaterialTheme.typography.titleMedium, 48 | fontWeight = FontWeight.Bold 49 | ) 50 | if (meta.contains("overview")) { 51 | Text( 52 | meta["overview"]!!.jsonPrimitive.content, 53 | overflow = TextOverflow.Ellipsis, 54 | style = MaterialTheme.typography.bodyMedium, 55 | color = MaterialTheme.colorScheme.onSurfaceVariant 56 | 57 | ) 58 | } 59 | } 60 | } 61 | HorizontalDivider() 62 | } -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/dev/ech0/torbox/multiplatform/ui/components/WatchSearchListItemN.kt: -------------------------------------------------------------------------------- 1 | package dev.ech0.torbox.multiplatform.ui.components 2 | 3 | import androidx.compose.foundation.ExperimentalFoundationApi 4 | import androidx.compose.foundation.combinedClickable 5 | import androidx.compose.foundation.layout.Arrangement 6 | import androidx.compose.foundation.layout.Column 7 | import androidx.compose.foundation.layout.Row 8 | import androidx.compose.foundation.layout.fillMaxHeight 9 | import androidx.compose.foundation.layout.fillMaxWidth 10 | import androidx.compose.foundation.layout.height 11 | import androidx.compose.foundation.layout.padding 12 | import androidx.compose.foundation.layout.width 13 | import androidx.compose.foundation.shape.RoundedCornerShape 14 | import androidx.compose.material3.HorizontalDivider 15 | import androidx.compose.material3.MaterialTheme 16 | import androidx.compose.material3.Text 17 | import androidx.compose.runtime.Composable 18 | import androidx.compose.ui.Alignment 19 | import androidx.compose.ui.Modifier 20 | import androidx.compose.ui.draw.clip 21 | import androidx.compose.ui.layout.ContentScale 22 | import androidx.compose.ui.text.font.FontWeight 23 | import androidx.compose.ui.text.style.TextOverflow 24 | import androidx.compose.ui.unit.dp 25 | import coil3.compose.AsyncImage 26 | import dev.ech0.torbox.multiplatform.api.tmdbApi 27 | import dev.ech0.torbox.multiplatform.ui.pages.watch.WatchSearchResult 28 | 29 | @OptIn(ExperimentalFoundationApi::class) 30 | @Composable 31 | fun WatchSearchListItemN(it: WatchSearchResult, onClick: () -> Unit){ 32 | Row( 33 | modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp) 34 | .height(200.dp).combinedClickable(onClick = {onClick()}), 35 | verticalAlignment = Alignment.CenterVertically 36 | ) { 37 | AsyncImage( 38 | model = it.poster?.let { it1 -> tmdbApi.imageHelper(it1) }, 39 | contentDescription = null, 40 | modifier = Modifier.padding(8.dp).padding(end = 16.dp) 41 | .clip(RoundedCornerShape(12.dp)).fillMaxHeight().width(125.dp), 42 | contentScale = ContentScale.FillHeight 43 | ) 44 | Column( 45 | verticalArrangement = Arrangement.Center 46 | ) { 47 | Text( 48 | it.title, 49 | style = MaterialTheme.typography.titleMedium, 50 | fontWeight = FontWeight.Bold 51 | ) 52 | it.summary?.let { it1 -> 53 | Text( 54 | it1, 55 | overflow = TextOverflow.Ellipsis, 56 | style = MaterialTheme.typography.bodyMedium, 57 | color = MaterialTheme.colorScheme.onSurfaceVariant 58 | ) 59 | } 60 | 61 | } 62 | } 63 | HorizontalDivider() 64 | } -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 74 | 75 | 76 | @rem Execute Gradle 77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 78 | 79 | :end 80 | @rem End local scope for the variables with windows NT shell 81 | if %ERRORLEVEL% equ 0 goto mainEnd 82 | 83 | :fail 84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 85 | rem the _cmd.exe /c_ return code! 86 | set EXIT_CODE=%ERRORLEVEL% 87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 89 | exit /b %EXIT_CODE% 90 | 91 | :mainEnd 92 | if "%OS%"=="Windows_NT" endlocal 93 | 94 | :omega 95 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/dev/ech0/torbox/multiplatform/ui/components/Bars.kt: -------------------------------------------------------------------------------- 1 | package dev.ech0.torbox.multiplatform.ui.components 2 | 3 | import androidx.compose.animation.Crossfade 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.statusBarsPadding 6 | import androidx.compose.material.icons.Icons 7 | import androidx.compose.material.icons.filled.Download 8 | import androidx.compose.material.icons.filled.Search 9 | import androidx.compose.material.icons.filled.Settings 10 | import androidx.compose.material.icons.filled.Tv 11 | import androidx.compose.material.icons.outlined.Download 12 | import androidx.compose.material.icons.outlined.Search 13 | import androidx.compose.material.icons.outlined.Settings 14 | import androidx.compose.material.icons.outlined.Tv 15 | import androidx.compose.material3.* 16 | import androidx.compose.runtime.* 17 | import androidx.compose.ui.Modifier 18 | import androidx.navigation.NavHostController 19 | import com.russhwolf.settings.Settings 20 | import dev.ech0.torbox.multiplatform.* 21 | import io.ktor.http.* 22 | 23 | @OptIn(ExperimentalMaterial3Api::class) 24 | @Composable 25 | fun TopBar() { 26 | TopAppBar( 27 | title = { Text("TorBox") }, 28 | colors = TopAppBarDefaults.topAppBarColors( 29 | containerColor = MaterialTheme.colorScheme.surface, 30 | titleContentColor = MaterialTheme.colorScheme.onSurface, 31 | ), 32 | modifier = Modifier.statusBarsPadding() 33 | ) 34 | } 35 | 36 | @Composable 37 | fun NavBar( 38 | navController: NavHostController 39 | ) { 40 | var selectedItem by remember { mutableIntStateOf(3) } 41 | val items = listOf("Search", "Watch", "Downloads", "Settings") 42 | val selectedIcons = listOf(Icons.Filled.Search, Icons.Filled.Tv, Icons.Filled.Download, Icons.Filled.Settings) 43 | val unselectedIcons = 44 | listOf(Icons.Outlined.Search, Icons.Outlined.Tv, Icons.Outlined.Download, Icons.Outlined.Settings) 45 | 46 | navController.addOnDestinationChangedListener { _, dest, _ -> 47 | //selectedItem = 48 | selectedItem = when (dest.route) { 49 | "Search" -> 0 50 | "Watch" -> 1 51 | "Downloads" -> 2 52 | "Settings" -> 3 53 | "Error/{what}" -> -1 54 | else -> { 55 | navController.navigate("Error/${"Navigation error.".encodeURLPath()}") 56 | -1 57 | } 58 | } 59 | } 60 | Column { 61 | if(Settings().getString("theme", "").startsWith("AMOLED")){ 62 | HorizontalDivider() 63 | } 64 | NavigationBar { 65 | items.forEachIndexed { index, item -> 66 | NavigationBarItem( 67 | icon = { 68 | Crossfade(targetState = (selectedItem == index)) { isSelected -> 69 | Icon( 70 | if (isSelected) selectedIcons[index] else unselectedIcons[index], 71 | contentDescription = item 72 | ) 73 | } 74 | }, 75 | label = { Text(item) }, 76 | selected = selectedItem == index, 77 | onClick = { selectedItem = index; navController.navigate(route = item) }, 78 | ) 79 | } 80 | } 81 | } 82 | } -------------------------------------------------------------------------------- /composeApp/lint-baseline.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 13 | 14 | 15 | 18 | 20 | 21 | 22 | 27 | 31 | 32 | 33 | 38 | 42 | 43 | 44 | 47 | 49 | 51 | 52 | 53 | 56 | 58 | 60 | 61 | 62 | 65 | 67 | 69 | 70 | 71 | 74 | 76 | 78 | 79 | 80 | 83 | 85 | 87 | 88 | 89 | 90 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/dev/ech0/torbox/multiplatform/App.kt: -------------------------------------------------------------------------------- 1 | package dev.ech0.torbox.multiplatform 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.PaddingValues 6 | import androidx.compose.foundation.layout.fillMaxSize 7 | import androidx.compose.foundation.layout.imePadding 8 | import androidx.compose.foundation.layout.padding 9 | import androidx.compose.material3.* 10 | import androidx.compose.runtime.* 11 | import androidx.compose.ui.Modifier 12 | import androidx.navigation.NavController 13 | import androidx.navigation.NavHostController 14 | import androidx.navigation.compose.NavHost 15 | import androidx.navigation.compose.composable 16 | import androidx.navigation.compose.rememberNavController 17 | import com.russhwolf.settings.Settings 18 | import dev.ech0.torbox.multiplatform.api.* 19 | import dev.ech0.torbox.multiplatform.theme.AppTheme 20 | import dev.ech0.torbox.multiplatform.ui.components.DisplayError 21 | import dev.ech0.torbox.multiplatform.ui.components.NavBar 22 | import dev.ech0.torbox.multiplatform.ui.components.TopBar 23 | import dev.ech0.torbox.multiplatform.ui.pages.DownloadsPage 24 | import dev.ech0.torbox.multiplatform.ui.pages.SearchPage 25 | import dev.ech0.torbox.multiplatform.ui.pages.SettingsPage 26 | import dev.ech0.torbox.multiplatform.ui.pages.watch.WatchSearchPage 27 | import dev.ech0.torbox.multiplatform.ui.pages.watch.WatchSearchPageN 28 | import kotlinx.coroutines.launch 29 | import org.jetbrains.compose.ui.tooling.preview.Preview 30 | 31 | val LocalNavController = compositionLocalOf { error("No NavController found!") } 32 | val LocalSnackbarHostState = compositionLocalOf { error("No SnackbarHostState found!") } 33 | 34 | @Composable 35 | expect fun PlayVideo(videoUrl: String) 36 | 37 | @Composable 38 | expect fun GetDynamicScheme(): ColorScheme? 39 | 40 | @Composable 41 | @Preview 42 | fun App() { 43 | var colorScheme by remember { 44 | mutableStateOf(Settings().getString("theme", "Torbox")) 45 | } 46 | var darkMode by remember { 47 | mutableStateOf(Settings().getBoolean("dark", true)) 48 | } 49 | AppTheme(themeName = colorScheme, darkTheme = darkMode) { 50 | var showContent by remember { mutableStateOf(false) } 51 | val navController = rememberNavController() 52 | val snackbarHostState = remember { SnackbarHostState() } 53 | val scope = rememberCoroutineScope() 54 | Box(modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)){ 55 | CompositionLocalProvider( 56 | LocalNavController provides navController, LocalSnackbarHostState provides snackbarHostState 57 | ) { 58 | Scaffold( 59 | topBar = { TopBar() }, 60 | bottomBar = { NavBar(navController) }, 61 | snackbarHost = { SnackbarHost(hostState = snackbarHostState) } 62 | ) { paddingValues -> 63 | Navigation(navController, paddingValues, { colorScheme = it }, { darkMode = it }) 64 | tmdbApi = TMDBApi() 65 | torboxAPI = TorboxAPI(Settings().getString("apiKey", "__"), navController) 66 | traktApi = Trakt() 67 | } 68 | 69 | } 70 | } 71 | } 72 | } 73 | 74 | @Composable 75 | fun Navigation( 76 | navController: NavHostController, paddingValues: PaddingValues, setColorScheme: (String) -> Unit, setDarkTheme: (Boolean) -> Unit 77 | ) { 78 | NavHost( 79 | navController = navController, startDestination = "Downloads", modifier = Modifier.padding(paddingValues) 80 | ) { 81 | composable("Downloads") { 82 | DownloadsPage() 83 | } 84 | composable("Watch") { 85 | WatchSearchPageN() 86 | } 87 | composable("Search") { 88 | SearchPage() 89 | } 90 | composable("Settings") { 91 | SettingsPage(setColorScheme, setDarkTheme) 92 | } 93 | composable("Error/{what}") { backStackEntry -> 94 | val what = backStackEntry.arguments?.getString("what") ?: "" 95 | DisplayError(false, what) 96 | } 97 | } 98 | 99 | } 100 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | agp = "8.8.2" 3 | android-compileSdk = "35" 4 | android-minSdk = "26" 5 | android-targetSdk = "35" 6 | androidx-activityCompose = "1.10.1" 7 | androidx-appcompat = "1.7.0" 8 | androidx-constraintlayout = "2.2.1" 9 | androidx-core-ktx = "1.15.0" 10 | androidx-espresso-core = "3.6.1" 11 | androidx-lifecycle = "2.8.4" 12 | androidx-material3 = "1.4.0-alpha09" 13 | androidx-test-junit = "1.2.1" 14 | coilComposeVersion = "3.0.4" 15 | compose-multiplatform = "1.8.0-alpha03" 16 | compose-multiplatform-backhandler = "1.8.0-alpha03" 17 | junit = "4.13.2" 18 | kotlin = "2.1.0" 19 | kotlinx-coroutines = "1.10.1" 20 | kotlinxDatetime = "0.6.1" 21 | kotlinxSerializationJson = "1.8.0" 22 | ktorClientAndroid = "3.0.4" 23 | ktorClientCore = "3.0.3" 24 | multiplatformSettings = "1.3.0" 25 | navigationCompose = "2.8.0-alpha13" 26 | navigationRuntimeKtx = "2.8.8" 27 | foundationLayoutAndroid = "1.7.8" 28 | okhttp = "5.0.0-alpha.14" 29 | eungabi = "0.4.0" 30 | 31 | [libraries] 32 | compose-multiplatform-backhandler = { module = "org.jetbrains.compose.ui:ui-backhandler", version.ref = "compose-multiplatform-backhandler" } 33 | eungabi = { module = "io.github.easternkite:eungabi", version.ref = "eungabi" } 34 | coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coilComposeVersion" } 35 | coil-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coilComposeVersion" } 36 | coil3-coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coilComposeVersion" } 37 | kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } 38 | kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } 39 | junit = { group = "junit", name = "junit", version.ref = "junit" } 40 | androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core-ktx" } 41 | androidx-test-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-junit" } 42 | androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "androidx-espresso-core" } 43 | androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidx-appcompat" } 44 | androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "androidx-material3" } 45 | androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "androidx-constraintlayout" } 46 | androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" } 47 | androidx-lifecycle-viewmodel = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "androidx-lifecycle" } 48 | androidx-lifecycle-runtime-compose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } 49 | kotlinx-coroutines-swing = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } 50 | androidx-navigation-runtime-ktx = { group = "androidx.navigation", name = "navigation-runtime-ktx", version.ref = "navigationRuntimeKtx" } 51 | kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" } 52 | kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } 53 | ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktorClientAndroid" } 54 | ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktorClientCore" } 55 | ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktorClientCore" } 56 | ktor-client-java = { module = "io.ktor:ktor-client-java", version.ref = "ktorClientCore" } 57 | ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktorClientCore" } 58 | multiplatform-settings = { module = "com.russhwolf:multiplatform-settings", version.ref = "multiplatformSettings" } 59 | multiplatform-settings-no-arg = { module = "com.russhwolf:multiplatform-settings-no-arg", version.ref = "multiplatformSettings" } 60 | navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "navigationCompose" } 61 | androidx-foundation-layout-android = { group = "androidx.compose.foundation", name = "foundation-layout-android", version.ref = "foundationLayoutAndroid" } 62 | okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } 63 | 64 | [plugins] 65 | androidApplication = { id = "com.android.application", version.ref = "agp" } 66 | androidLibrary = { id = "com.android.library", version.ref = "agp" } 67 | composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" } 68 | composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } 69 | kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/dev/ech0/torbox/multiplatform/ui/components/Error.kt: -------------------------------------------------------------------------------- 1 | package dev.ech0.torbox.multiplatform.ui.components 2 | 3 | 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.fillMaxSize 6 | import androidx.compose.foundation.layout.fillMaxWidth 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.foundation.layout.wrapContentHeight 9 | import androidx.compose.material3.Button 10 | import androidx.compose.material3.MaterialTheme 11 | import androidx.compose.material3.Text 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.runtime.rememberCoroutineScope 14 | import androidx.compose.ui.Alignment 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.platform.LocalClipboardManager 17 | import androidx.compose.ui.text.AnnotatedString 18 | import androidx.compose.ui.text.style.TextAlign 19 | import androidx.compose.ui.unit.dp 20 | import dev.ech0.torbox.multiplatform.LocalSnackbarHostState 21 | import kotlinx.coroutines.launch 22 | 23 | val errorStrings = arrayOf( 24 | "oopsies :3", 25 | "well shit.", 26 | "that's not supposed to happen.", 27 | "that's totally supposed to happen.", 28 | "owchiee :(", 29 | "MAYDAY MAYDAY MAYDAY!!", 30 | "not againnn", 31 | "AAA A BUG", 32 | "holy hell", 33 | "holy api calls", 34 | "This isnt even a bruh moment anymore. What the fuck man.", 35 | "I'm sad. Life is sad.", 36 | "man", 37 | "You cheeky little sausage. You know what you did.", 38 | "Not my problem.", 39 | "Too bad, so sad.", 40 | "It wasnt me, I swear!", 41 | "PANPAN!! PANPAN!! PANPAN!!!", 42 | "You've gotta be kidding me.", 43 | "No more linux isos :(", 44 | "oopsie woopsie (´ω`), we made a fucky wucky~~ (◡w◡) :3", 45 | "this is what you get. we know what you did. all sins can be repented with time and effort. start now.", 46 | "rawr x3 uwu *nuzzles you* *pounces on you* uwu u so warm :3" 47 | ) 48 | 49 | @Composable 50 | fun DisplayError( 51 | recoverable: Boolean = false, 52 | what: String = Exception("Achievement get: How did we get here?").toString() 53 | ) { 54 | val clipboardManager = LocalClipboardManager.current 55 | val snackbarHostState = LocalSnackbarHostState.current 56 | val scope = rememberCoroutineScope() 57 | Column( 58 | modifier = Modifier 59 | .fillMaxSize() 60 | .wrapContentHeight(align = Alignment.CenterVertically), 61 | horizontalAlignment = Alignment.CenterHorizontally, 62 | 63 | ) { 64 | Text( 65 | text = ">.<", 66 | textAlign = TextAlign.Center, 67 | modifier = Modifier 68 | .fillMaxWidth() 69 | .padding(bottom = 8.dp), 70 | style = MaterialTheme.typography.displayLarge 71 | ) 72 | Text( 73 | text = errorStrings.random(), 74 | textAlign = TextAlign.Center, 75 | modifier = Modifier 76 | .fillMaxWidth() 77 | .padding(bottom = 8.dp) 78 | .padding(horizontal = 8.dp), 79 | style = MaterialTheme.typography.titleMedium 80 | ) 81 | if(!what.replace("\n", "").matches(Regex(".*\\..*\\..*"))){ 82 | Text( 83 | text = what.toString(), 84 | textAlign = TextAlign.Center, 85 | modifier = Modifier 86 | .fillMaxWidth() 87 | .padding(bottom = 16.dp), 88 | style = MaterialTheme.typography.bodyMedium 89 | ) 90 | }else { 91 | Text( 92 | text = "looks like we messed up. ${if (recoverable) "reloading..." else ""}", 93 | textAlign = TextAlign.Center, 94 | modifier = Modifier 95 | .fillMaxWidth() 96 | .padding(bottom = 16.dp), 97 | style = MaterialTheme.typography.bodyMedium 98 | ) 99 | } 100 | if (!recoverable) { 101 | Button( 102 | onClick = { 103 | clipboardManager.setText(AnnotatedString(what.toString())) 104 | scope.launch { 105 | snackbarHostState.showSnackbar("Copied!") 106 | } 107 | }, 108 | modifier = Modifier.padding(bottom = 8.dp) 109 | ) { Text("grab what()") } 110 | Text( 111 | text = "If you didn't expect this to happen, click the above button and send it to the developer.\nIf you did expect it to happen, send it to me anyways. So I can tell the expect the program to expect it to happen. Or something.", 112 | textAlign = TextAlign.Center, 113 | modifier = Modifier 114 | .fillMaxWidth() 115 | .padding(bottom = 16.dp) 116 | .padding(horizontal = 24.dp), 117 | style = MaterialTheme.typography.bodySmall, 118 | color = MaterialTheme.colorScheme.onSurfaceVariant 119 | ) 120 | } 121 | } 122 | } -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/dev/ech0/torbox/multiplatform/ui/components/TraktPrompt.kt: -------------------------------------------------------------------------------- 1 | package dev.ech0.torbox.multiplatform.ui.components 2 | 3 | import androidx.compose.foundation.layout.* 4 | import androidx.compose.foundation.text.selection.SelectionContainer 5 | import androidx.compose.material3.Button 6 | import androidx.compose.material3.Card 7 | import androidx.compose.material3.Icon 8 | import androidx.compose.material3.MaterialTheme 9 | import androidx.compose.material3.Text 10 | import androidx.compose.runtime.* 11 | import androidx.compose.ui.Alignment 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.platform.LocalClipboardManager 14 | import androidx.compose.ui.platform.LocalUriHandler 15 | import androidx.compose.ui.text.font.FontWeight 16 | import androidx.compose.ui.text.style.TextAlign 17 | import androidx.compose.ui.unit.dp 18 | import androidx.compose.ui.window.Dialog 19 | import androidx.compose.ui.zIndex 20 | import androidx.navigation.NavController 21 | import com.russhwolf.settings.Settings 22 | import dev.ech0.torbox.multiplatform.api.Trakt 23 | import dev.ech0.torbox.multiplatform.api.traktApi 24 | import kotlinx.coroutines.delay 25 | import kotlinx.coroutines.launch 26 | import kotlinx.serialization.json.JsonObject 27 | import kotlinx.serialization.json.int 28 | import kotlinx.serialization.json.jsonPrimitive 29 | import org.jetbrains.compose.resources.painterResource 30 | import utac.composeapp.generated.resources.Res 31 | import utac.composeapp.generated.resources.trakt 32 | 33 | @Composable 34 | fun TraktPrompt(dismiss: () -> Unit, navController: NavController) { 35 | var shouldLoad by remember { mutableStateOf(false) } 36 | val scope = rememberCoroutineScope() 37 | var traktResponseJSON by remember { mutableStateOf(JsonObject(emptyMap())) } 38 | var traktResponded by remember { mutableStateOf(false) } 39 | var complete by remember { mutableStateOf(false) } 40 | val uriHandler = LocalUriHandler.current 41 | val clipboardManager = LocalClipboardManager.current 42 | 43 | LaunchedEffect(true) { 44 | scope.launch { 45 | traktResponseJSON = traktApi.getAuthCode() 46 | traktResponded = true 47 | } 48 | } 49 | LaunchedEffect(traktResponded) { 50 | if (traktResponded) { 51 | while (!complete) { 52 | val resp = traktApi.getRefreshToken(traktResponseJSON["device_code"]!!.jsonPrimitive.content) 53 | if (resp != "") { 54 | Settings().putString("traktToken", resp) 55 | traktApi = Trakt() 56 | dismiss() 57 | traktResponded = false 58 | } 59 | delay(traktResponseJSON["interval"]!!.jsonPrimitive.int * 1000L) 60 | } 61 | } 62 | } 63 | Dialog(onDismissRequest = dismiss) { 64 | Card( 65 | modifier = Modifier.fillMaxWidth().padding(16.dp).wrapContentSize(), 66 | ) { 67 | Column(modifier = Modifier.padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally) { 68 | Icon( 69 | painterResource(Res.drawable.trakt), "Input API Key", modifier = Modifier.padding(bottom = 0.dp), tint = MaterialTheme.colorScheme.primary 70 | ) 71 | Text( 72 | "Log in to Trakt", 73 | style = MaterialTheme.typography.titleLarge, 74 | modifier = Modifier.padding(top = 16.dp) 75 | ) 76 | if (traktResponded) { 77 | SelectionContainer { 78 | Text( 79 | traktResponseJSON["user_code"]!!.jsonPrimitive.content, 80 | style = MaterialTheme.typography.displayMedium, 81 | fontWeight = FontWeight.Black, 82 | color = MaterialTheme.colorScheme.secondary, 83 | modifier = Modifier.padding(top = 16.dp) 84 | ) 85 | 86 | } 87 | SelectionContainer { 88 | Text( 89 | "Go to ${traktResponseJSON["verification_url"]!!.jsonPrimitive.content} and enter the above code, or click this button", 90 | style = MaterialTheme.typography.bodyMedium, 91 | textAlign = TextAlign.Center, 92 | modifier = Modifier.padding(top = 16.dp) 93 | ) 94 | } 95 | Button(onClick = { 96 | uriHandler.openUri( 97 | traktResponseJSON["verification_url"]!!.jsonPrimitive.content + "/${ 98 | traktResponseJSON["user_code"]!!.jsonPrimitive.content 99 | }" 100 | ) 101 | }, modifier = Modifier.padding(top = 16.dp)) { 102 | Text("Copy & Open Trakt") 103 | } 104 | } else { 105 | Box( 106 | modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min).zIndex(2f) 107 | ) { 108 | LoadingScreen() 109 | } 110 | } 111 | } 112 | 113 | } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /composeApp/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.compose.desktop.application.dsl.TargetFormat 2 | import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi 3 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget 4 | import java.util.Properties 5 | 6 | val version = "2.1.0" 7 | val versionNumber = 2 8 | 9 | val properties = Properties() 10 | properties.load(project.rootProject.file("key.properties").inputStream()) 11 | val tmdbKey: String = properties.getProperty("TMDB_KEY") 12 | val traktKey: String = properties.getProperty("TRAKT_KEY") 13 | val traktSecret: String = properties.getProperty("TRAKT_SECRET") 14 | 15 | // https://stackoverflow.com/a/74771876 16 | // hi 17 | val buildConfigGenerator by tasks.registering(Sync::class) { 18 | from( 19 | resources.text.fromString( 20 | """ 21 | package dev.ech0.torbox.multiplatform 22 | 23 | object BuildConfig{ 24 | const val VERSION = "$version" 25 | const val TMDB_KEY = $tmdbKey 26 | const val TRAKT_KEY = $traktKey 27 | const val TRAKT_SECRET = $traktSecret 28 | } 29 | """.trimIndent() 30 | ) 31 | ) { 32 | rename { "BuildConfig.kt" } 33 | into("dev/ech0/torbox/multiplatform/") 34 | } 35 | into(layout.buildDirectory.dir("generated/source/kotlin")) 36 | 37 | } 38 | plugins { 39 | alias(libs.plugins.kotlinMultiplatform) 40 | alias(libs.plugins.androidApplication) 41 | alias(libs.plugins.composeMultiplatform) 42 | alias(libs.plugins.composeCompiler) 43 | kotlin("plugin.serialization") version "2.1.0" 44 | } 45 | 46 | dependencies { 47 | implementation(libs.androidx.foundation.layout.android) 48 | debugImplementation(compose.uiTooling) 49 | } 50 | 51 | kotlin { 52 | androidTarget { 53 | @OptIn(ExperimentalKotlinGradlePluginApi::class) compilerOptions { 54 | jvmTarget.set(JvmTarget.JVM_11) 55 | } 56 | } 57 | listOf( 58 | iosX64(), iosArm64(), iosSimulatorArm64() 59 | ).forEach { iosTarget -> 60 | iosTarget.binaries.framework { 61 | baseName = "ComposeApp" 62 | isStatic = true 63 | } 64 | } 65 | 66 | jvm("desktop") {} 67 | sourceSets { 68 | val desktopMain by getting 69 | val commonMain by getting { 70 | kotlin.srcDirs(buildConfigGenerator.map { it.destinationDir }) 71 | } 72 | androidMain.dependencies { 73 | implementation(compose.preview) 74 | implementation(libs.androidx.activity.compose) 75 | implementation(libs.ktor.client.okhttp) 76 | } 77 | commonMain.dependencies { 78 | implementation(compose.runtime) 79 | implementation(compose.foundation) 80 | implementation(compose.material3) 81 | implementation(compose.ui) 82 | implementation(compose.components.resources) 83 | implementation(compose.components.uiToolingPreview) 84 | implementation(compose.materialIconsExtended) 85 | implementation(libs.ktor.client.core) 86 | implementation(libs.androidx.lifecycle.viewmodel) 87 | implementation(libs.androidx.lifecycle.runtime.compose) 88 | implementation(libs.navigation.compose) 89 | implementation(libs.kotlinx.serialization.json) 90 | implementation(libs.kotlinx.datetime) 91 | implementation(libs.multiplatform.settings) 92 | implementation(libs.multiplatform.settings.no.arg) 93 | implementation(libs.coil3.coil.compose) 94 | implementation(libs.coil.network.ktor3) 95 | implementation(compose.components.resources) 96 | implementation(libs.coil.svg) 97 | implementation(libs.compose.multiplatform.backhandler) 98 | } 99 | desktopMain.dependencies { 100 | implementation(compose.desktop.currentOs) 101 | implementation(libs.kotlinx.coroutines.swing) 102 | implementation(libs.ktor.client.java) 103 | } 104 | iosMain.dependencies { 105 | implementation(libs.ktor.client.darwin) 106 | } 107 | } 108 | } 109 | 110 | android { 111 | namespace = "dev.ech0.torbox.multiplatform" 112 | compileSdk = libs.versions.android.compileSdk.get().toInt() 113 | 114 | defaultConfig { 115 | applicationId = "dev.ech0.torbox.multiplatform" 116 | minSdk = libs.versions.android.minSdk.get().toInt() 117 | targetSdk = libs.versions.android.targetSdk.get().toInt() 118 | versionCode = versionNumber 119 | versionName = version 120 | } 121 | packaging { 122 | resources { 123 | excludes += "/META-INF/{AL2.0,LGPL2.1}" 124 | } 125 | } 126 | buildTypes { 127 | getByName("release") { 128 | isMinifyEnabled = true 129 | } 130 | } 131 | compileOptions { 132 | sourceCompatibility = JavaVersion.VERSION_11 133 | targetCompatibility = JavaVersion.VERSION_11 134 | } 135 | lint { 136 | baseline = file("lint-baseline.xml") 137 | } 138 | } 139 | 140 | compose.desktop { 141 | application { 142 | mainClass = "dev.ech0.torbox.multiplatform.MainKt" 143 | 144 | nativeDistributions { 145 | targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) 146 | packageName = "dev.ech0.torbox.multiplatform" 147 | packageVersion = version 148 | } 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/dev/ech0/torbox/multiplatform/ui/components/ApiPrompt.kt: -------------------------------------------------------------------------------- 1 | package dev.ech0.torbox.multiplatform.ui.components 2 | 3 | import androidx.compose.foundation.layout.* 4 | import androidx.compose.material.icons.Icons 5 | import androidx.compose.material.icons.filled.Password 6 | import androidx.compose.material.icons.filled.Visibility 7 | import androidx.compose.material.icons.filled.VisibilityOff 8 | import androidx.compose.material.icons.outlined.ContentPaste 9 | import androidx.compose.material3.* 10 | import androidx.compose.runtime.* 11 | import androidx.compose.ui.Alignment 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.platform.LocalClipboard 14 | import androidx.compose.ui.platform.LocalClipboardManager 15 | import androidx.compose.ui.text.input.PasswordVisualTransformation 16 | import androidx.compose.ui.text.input.VisualTransformation 17 | import androidx.compose.ui.text.style.TextAlign 18 | import androidx.compose.ui.unit.dp 19 | import androidx.compose.ui.window.Dialog 20 | import androidx.compose.ui.zIndex 21 | import androidx.navigation.NavController 22 | import com.russhwolf.settings.Settings 23 | import dev.ech0.torbox.multiplatform.api.torboxAPI 24 | import dev.ech0.torbox.multiplatform.getPlatform 25 | import io.ktor.http.* 26 | import kotlinx.coroutines.launch 27 | 28 | @Composable 29 | fun ApiPrompt(dismiss: () -> Unit, navController: NavController) { 30 | var apiKeySet by remember { mutableStateOf("") } 31 | var showApikey by remember { mutableStateOf(if(getPlatform().name == "iOS") true else false) } 32 | var shouldLoad by remember { mutableStateOf(false) } 33 | val scope = rememberCoroutineScope() 34 | val clipboardManager = LocalClipboardManager.current 35 | Dialog(onDismissRequest = dismiss) { 36 | Card( 37 | modifier = Modifier 38 | .fillMaxWidth() 39 | .padding(16.dp) 40 | .wrapContentSize(), 41 | 42 | ) { 43 | Column(modifier = Modifier.padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally) { 44 | Icon( 45 | Icons.Filled.Password, "Input API Key", modifier = Modifier.padding(bottom = 0.dp) 46 | ) 47 | Text( 48 | "Input API Key", 49 | style = MaterialTheme.typography.titleLarge, 50 | modifier = Modifier.padding(top = 16.dp) 51 | ) 52 | 53 | OutlinedTextField( 54 | modifier = Modifier.padding(top = 24.dp, bottom = 16.dp), 55 | value = apiKeySet, 56 | singleLine = true, 57 | visualTransformation = if (showApikey) { 58 | VisualTransformation.None 59 | } else { 60 | PasswordVisualTransformation() 61 | }, 62 | onValueChange = { newVal -> 63 | apiKeySet = newVal 64 | 65 | }, 66 | trailingIcon = { 67 | if(getPlatform().name != "iOS"){ 68 | IconButton(content = { 69 | if (showApikey) { 70 | Icon(Icons.Filled.VisibilityOff, null) 71 | } else { 72 | Icon(Icons.Filled.Visibility, null) 73 | } 74 | }, onClick = { showApikey = !showApikey }) 75 | } 76 | }, leadingIcon = { 77 | IconButton(content = { 78 | Icon(Icons.Outlined.ContentPaste, null) 79 | }, onClick = {scope.launch { 80 | apiKeySet = clipboardManager.getText()?.text ?: "" 81 | }}) 82 | }) 83 | Text( 84 | "Your API Key will be stored locally and sent only to Torbox servers, nowhere else.", 85 | style = MaterialTheme.typography.bodySmall, 86 | color = MaterialTheme.colorScheme.onSurfaceVariant, 87 | textAlign = TextAlign.Center, 88 | modifier = Modifier.padding(bottom = 24.dp) 89 | ) 90 | if (!shouldLoad) { 91 | Button(onClick = { 92 | scope.launch { 93 | try { 94 | shouldLoad = true 95 | torboxAPI.setApiKey(apiKeySet) 96 | if (torboxAPI.checkApiKey(apiKeySet)) { 97 | Settings().putString("apiKey", apiKeySet) 98 | } else { 99 | torboxAPI.setApiKey("__") 100 | navController.navigate("Error/${"Invalid API Key".encodeURLPath()}") 101 | } 102 | shouldLoad = false 103 | dismiss() 104 | } catch (e: Exception) { 105 | navController.navigate("Error/${e.toString().encodeURLPath()}") 106 | } 107 | } 108 | }) { 109 | Text("Submit") 110 | } 111 | } else { 112 | Box( 113 | modifier = Modifier 114 | .fillMaxWidth() 115 | .height(IntrinsicSize.Min) 116 | .zIndex(2f) // Ensure this box is above other content 117 | ) { 118 | LoadingScreen() 119 | } 120 | } 121 | 122 | } 123 | 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/dev/ech0/torbox/multiplatform/ui/pages/SearchPage.kt: -------------------------------------------------------------------------------- 1 | package dev.ech0.torbox.multiplatform.ui.pages 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.fillMaxSize 5 | import androidx.compose.foundation.layout.fillMaxWidth 6 | import androidx.compose.foundation.layout.imePadding 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.foundation.lazy.LazyColumn 9 | import androidx.compose.foundation.lazy.items 10 | import androidx.compose.foundation.shape.RoundedCornerShape 11 | import androidx.compose.foundation.text.input.rememberTextFieldState 12 | import androidx.compose.material.icons.Icons 13 | import androidx.compose.material.icons.filled.Search 14 | import androidx.compose.material3.* 15 | import androidx.compose.runtime.Composable 16 | import androidx.compose.runtime.LaunchedEffect 17 | import androidx.compose.runtime.getValue 18 | import androidx.compose.runtime.mutableStateOf 19 | import androidx.compose.runtime.remember 20 | import androidx.compose.runtime.rememberCoroutineScope 21 | import androidx.compose.runtime.setValue 22 | import androidx.compose.ui.Modifier 23 | import androidx.compose.ui.draw.blur 24 | import androidx.compose.ui.focus.FocusRequester 25 | import androidx.compose.ui.focus.focusRequester 26 | import androidx.compose.ui.graphics.graphicsLayer 27 | import androidx.compose.ui.platform.LocalFocusManager 28 | import androidx.compose.ui.unit.dp 29 | import androidx.navigation.NavController 30 | import com.russhwolf.settings.Settings 31 | import dev.ech0.torbox.multiplatform.LocalNavController 32 | import dev.ech0.torbox.multiplatform.LocalSnackbarHostState 33 | import dev.ech0.torbox.multiplatform.api.torboxAPI 34 | import dev.ech0.torbox.multiplatform.ui.components.LoadingScreen 35 | import io.ktor.http.* 36 | import kotlinx.coroutines.launch 37 | import kotlinx.serialization.json.JsonObject 38 | import kotlinx.serialization.json.jsonArray 39 | import kotlinx.serialization.json.jsonObject 40 | import dev.ech0.torbox.multiplatform.ui.components.SearchItem 41 | 42 | @OptIn(ExperimentalMaterial3Api::class) 43 | @Composable 44 | fun SearchPage() { 45 | val navController = LocalNavController.current 46 | val focusRequester = remember { FocusRequester() } 47 | val focusManager = LocalFocusManager.current 48 | var textFieldState by remember { mutableStateOf("") } 49 | var results by remember { mutableStateOf>(emptyList()) } 50 | var shouldLoad by remember { mutableStateOf(false) } 51 | val scope = rememberCoroutineScope() 52 | val topBar = Settings().getBoolean("searchTop", false) 53 | val snackbarHostState = LocalSnackbarHostState.current 54 | LaunchedEffect(true) { 55 | focusRequester.requestFocus() 56 | } 57 | Column( 58 | modifier = Modifier.fillMaxSize().blur(if(shouldLoad){10.dp}else{0.dp}) 59 | ){ 60 | if(!topBar){ 61 | LazyColumn( 62 | modifier = Modifier 63 | .fillMaxSize().weight(1f) 64 | ) { 65 | items(results) { result -> 66 | SearchItem(result, {shouldLoad = it}, navController) 67 | } 68 | } 69 | } 70 | SearchBar( 71 | inputField = { 72 | SearchBarDefaults.InputField( 73 | query = textFieldState, 74 | onQueryChange = { 75 | textFieldState = it 76 | }, 77 | onSearch = { 78 | focusManager.clearFocus() 79 | scope.launch { 80 | try{ 81 | if(textFieldState != ""){ 82 | shouldLoad = true 83 | torboxAPI 84 | var data = 85 | torboxAPI.searchTorrents(textFieldState)["data"]!!.jsonObject["torrents"]!!.jsonArray 86 | results = List(data.size) { index -> data[index].jsonObject} 87 | if(Settings().getInt("plan", 4) == 2 && Settings().getBoolean("usenet", true)){ 88 | data = torboxAPI.searchUsenet(textFieldState)["data"]!!.jsonObject["nzbs"]!!.jsonArray 89 | results = results.plus(List(data.size) { index -> data[index].jsonObject }) 90 | results = results.shuffled() 91 | } 92 | shouldLoad = false 93 | }else{ 94 | snackbarHostState.showSnackbar("Search for something, you goober.") 95 | } 96 | }catch(e: Exception){ 97 | navController.navigate("Error/${e.toString().encodeURLPath()}") 98 | } 99 | } 100 | }, 101 | expanded = false, 102 | onExpandedChange = { }, 103 | placeholder = { Text("Search away, matey.") }, 104 | leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) } 105 | ) 106 | }, 107 | modifier = if(topBar){ 108 | Modifier 109 | .focusRequester(focusRequester) 110 | .fillMaxWidth() 111 | .padding(horizontal = 10.dp) 112 | }else{ 113 | Modifier 114 | .focusRequester(focusRequester) 115 | .fillMaxWidth() 116 | }, 117 | shape = if (topBar) { 118 | RoundedCornerShape(25.dp, 25.dp, 25.dp, 25.dp) 119 | } else { 120 | RoundedCornerShape(24.dp, 24.dp, 0.dp, 0.dp) 121 | }, 122 | expanded = false, 123 | onExpandedChange = { }, 124 | ) {} 125 | if(topBar){ 126 | LazyColumn( 127 | modifier = Modifier 128 | .fillMaxSize().weight(1f).padding(top = 12.dp) 129 | ) { 130 | items(results) { result -> 131 | SearchItem(result, {shouldLoad = it}, navController) 132 | } 133 | } 134 | } 135 | } 136 | 137 | if (shouldLoad) { 138 | LoadingScreen() 139 | } 140 | } -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/dev/ech0/torbox/multiplatform/ui/components/SearchItem.kt: -------------------------------------------------------------------------------- 1 | package dev.ech0.torbox.multiplatform.ui.components 2 | 3 | import androidx.compose.foundation.ExperimentalFoundationApi 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.Row 6 | import androidx.compose.foundation.layout.fillMaxWidth 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.foundation.layout.size 9 | import androidx.compose.foundation.shape.RoundedCornerShape 10 | import androidx.compose.material.icons.Icons 11 | import androidx.compose.material.icons.automirrored.filled.ArrowForward 12 | import androidx.compose.material.icons.automirrored.filled.InsertDriveFile 13 | import androidx.compose.material.icons.filled.Add 14 | import androidx.compose.material.icons.filled.Newspaper 15 | import androidx.compose.material.icons.outlined.Check 16 | import androidx.compose.material.icons.outlined.Storage 17 | import androidx.compose.material3.HorizontalDivider 18 | import androidx.compose.material3.Icon 19 | import androidx.compose.material3.IconButton 20 | import androidx.compose.material3.IconButtonColors 21 | import androidx.compose.material3.MaterialTheme 22 | import androidx.compose.material3.Text 23 | import androidx.compose.runtime.Composable 24 | import androidx.compose.runtime.getValue 25 | import androidx.compose.runtime.mutableStateOf 26 | import androidx.compose.runtime.remember 27 | import androidx.compose.runtime.rememberCoroutineScope 28 | import androidx.compose.runtime.setValue 29 | import androidx.compose.ui.Alignment 30 | import androidx.compose.ui.Modifier 31 | import androidx.compose.ui.draw.blur 32 | import androidx.compose.ui.draw.clip 33 | import androidx.compose.ui.platform.LocalHapticFeedback 34 | import androidx.compose.ui.platform.LocalUriHandler 35 | import androidx.compose.ui.unit.dp 36 | import androidx.navigation.NavController 37 | import com.russhwolf.settings.Settings 38 | import dev.ech0.torbox.multiplatform.LocalSnackbarHostState 39 | import dev.ech0.torbox.multiplatform.api.torboxAPI 40 | import dev.ech0.torbox.multiplatform.formatFileSize 41 | import io.ktor.http.encodeURLPath 42 | import kotlinx.coroutines.launch 43 | import kotlinx.serialization.json.JsonObject 44 | import kotlinx.serialization.json.boolean 45 | import kotlinx.serialization.json.int 46 | import kotlinx.serialization.json.jsonPrimitive 47 | import kotlinx.serialization.json.long 48 | 49 | @OptIn(ExperimentalFoundationApi::class) 50 | @Composable 51 | fun SearchItem( 52 | torrent: JsonObject, setLoadingScreen: (Boolean) -> Unit, navController: NavController 53 | ) { 54 | val scope = rememberCoroutineScope() 55 | val uriHandler = LocalUriHandler.current 56 | val haptics = LocalHapticFeedback.current 57 | var expanded by remember { mutableStateOf(false) } 58 | val snackbarHostState = LocalSnackbarHostState.current 59 | Row( 60 | modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp, horizontal = 20.dp), 61 | verticalAlignment = Alignment.CenterVertically 62 | ) { 63 | Icon( 64 | if (torrent["type"]!!.jsonPrimitive.content == "usenet") Icons.Filled.Newspaper else Icons.AutoMirrored.Filled.InsertDriveFile, 65 | "File", 66 | modifier = Modifier.padding(end = 8.dp) 67 | ) 68 | Column(modifier = Modifier.weight(1f)) { 69 | Text( 70 | text = torrent["raw_title"]!!.jsonPrimitive.content, 71 | style = MaterialTheme.typography.titleMedium, 72 | color = MaterialTheme.colorScheme.onSurface, 73 | modifier = if (Settings().getBoolean("blurDL", false)) { 74 | Modifier.clip(RoundedCornerShape(6.dp)).blur(10.dp) 75 | } else { 76 | Modifier 77 | } 78 | ) 79 | Text( 80 | text = "${formatFileSize(torrent["size"]!!.jsonPrimitive.long)} ${if (torrent["type"]!!.jsonPrimitive.content != "usenet") ", ${torrent["last_known_seeders"]!!.jsonPrimitive.int} seeding" else ""}", 81 | style = MaterialTheme.typography.bodySmall, 82 | color = MaterialTheme.colorScheme.onSurfaceVariant 83 | ) 84 | } 85 | if (torrent["owned"]!!.jsonPrimitive.boolean) { 86 | Icon( 87 | Icons.Outlined.Check, 88 | "Owned", 89 | modifier = Modifier.padding(horizontal = 6.dp).size(24.dp), 90 | tint = MaterialTheme.colorScheme.outline 91 | ) 92 | 93 | } else if (torrent["cached"]!!.jsonPrimitive.boolean) { 94 | Icon( 95 | Icons.Outlined.Storage, 96 | "Cached", 97 | modifier = Modifier.padding(horizontal = 6.dp).size(24.dp), 98 | tint = MaterialTheme.colorScheme.outline 99 | ) 100 | } 101 | 102 | IconButton(modifier = Modifier.padding(start = 16.dp).size(32.dp), content = { 103 | if (torrent["owned"]!!.jsonPrimitive.boolean) { 104 | Icon( 105 | Icons.AutoMirrored.Filled.ArrowForward, 106 | contentDescription = "Go to downloads", 107 | modifier = Modifier.padding(4.dp) 108 | ) 109 | } else { 110 | Icon( 111 | Icons.Filled.Add, 112 | contentDescription = "Add torrent", 113 | modifier = Modifier.padding(4.dp) 114 | ) 115 | } 116 | }, onClick = { 117 | if (torrent["owned"]!!.jsonPrimitive.boolean) { 118 | navController.navigate("Downloads") 119 | } else { 120 | scope.launch { 121 | try { 122 | setLoadingScreen(true) 123 | if (torrent["type"]!!.jsonPrimitive.content == "usenet") { 124 | torboxAPI.createUsenet(torrent["nzb"]!!.jsonPrimitive.content) 125 | } else { 126 | torboxAPI.createTorrent(torrent["magnet"]!!.jsonPrimitive.content) 127 | } 128 | setLoadingScreen(false) 129 | navController.navigate("Downloads") 130 | snackbarHostState.showSnackbar("Torrent created successfully!") 131 | } catch (e: Exception) { 132 | navController.navigate("Error/${e.toString().encodeURLPath()}") 133 | } 134 | } 135 | } 136 | 137 | }, colors = IconButtonColors( 138 | containerColor = MaterialTheme.colorScheme.secondaryContainer, 139 | contentColor = MaterialTheme.colorScheme.onSecondaryContainer, 140 | disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerHighest, 141 | disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant 142 | ) 143 | ) 144 | } 145 | 146 | HorizontalDivider(modifier = Modifier.padding(horizontal = 20.dp)) 147 | } 148 | 149 | 150 | 151 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/dev/ech0/torbox/multiplatform/ui/pages/watch/WatchSearchPage.kt: -------------------------------------------------------------------------------- 1 | package dev.ech0.torbox.multiplatform.ui.pages.watch 2 | 3 | import androidx.compose.animation.Crossfade 4 | import androidx.compose.foundation.layout.* 5 | import androidx.compose.foundation.lazy.LazyColumn 6 | import androidx.compose.foundation.lazy.items 7 | import androidx.compose.foundation.shape.RoundedCornerShape 8 | import androidx.compose.foundation.text.input.rememberTextFieldState 9 | import androidx.compose.material.icons.Icons 10 | import androidx.compose.material.icons.filled.Search 11 | import androidx.compose.material3.* 12 | import androidx.compose.runtime.* 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.draw.blur 15 | import androidx.compose.ui.draw.drawWithContent 16 | import androidx.compose.ui.focus.FocusRequester 17 | import androidx.compose.ui.focus.focusRequester 18 | import androidx.compose.ui.graphics.* 19 | import androidx.compose.ui.platform.LocalFocusManager 20 | import androidx.compose.ui.unit.dp 21 | import com.russhwolf.settings.Settings 22 | import dev.ech0.torbox.multiplatform.LocalNavController 23 | import dev.ech0.torbox.multiplatform.api.tmdbApi 24 | import dev.ech0.torbox.multiplatform.ui.components.LoadingScreen 25 | import dev.ech0.torbox.multiplatform.ui.components.WatchListItem 26 | import kotlinx.coroutines.launch 27 | import kotlinx.serialization.json.JsonObject 28 | import kotlinx.serialization.json.double 29 | import kotlinx.serialization.json.jsonObject 30 | import kotlinx.serialization.json.jsonPrimitive 31 | 32 | @OptIn(ExperimentalMaterial3Api::class) 33 | @Composable 34 | fun WatchSearchPage() { 35 | val navController = LocalNavController.current 36 | var textFieldState by remember { mutableStateOf("") } 37 | val topBar = Settings().getBoolean("searchTop", false) 38 | val focusRequester = remember { FocusRequester() } 39 | val focusManager = LocalFocusManager.current 40 | var shouldLoad by remember { mutableStateOf(false) } 41 | var results by remember { mutableStateOf>(emptyList()) } 42 | val scope = rememberCoroutineScope() 43 | var page by remember { mutableStateOf("") } 44 | var meta by remember { mutableStateOf(JsonObject(emptyMap())) } 45 | LaunchedEffect(true) { 46 | focusRequester.requestFocus() 47 | }/* TODO: BackHandler(enabled = page != "") { 48 | page = "" 49 | }*/ 50 | Crossfade(page) { state -> 51 | if (state == "") { 52 | Column(modifier = Modifier.blur(if (shouldLoad) 10.dp else 0.dp).fillMaxSize()) { 53 | if (!topBar) { 54 | LazyColumn(modifier = Modifier.fillMaxSize().weight(1f).graphicsLayer { alpha = 0.99F } 55 | .drawWithContent { 56 | val colors = listOf( 57 | Color.Black, Color.Black, Color.Black, Color.Transparent 58 | ) 59 | drawContent() 60 | drawRect( 61 | brush = Brush.verticalGradient(colors), blendMode = BlendMode.DstIn 62 | ) 63 | }) { 64 | items(results) { result -> 65 | WatchListItem(result, { page = it }, { meta = it; }) 66 | } 67 | } 68 | } 69 | Column { 70 | SearchBar( 71 | inputField = { 72 | SearchBarDefaults.InputField( 73 | query = textFieldState, 74 | onQueryChange = { 75 | textFieldState = it 76 | }, 77 | onSearch = { 78 | focusManager.clearFocus() 79 | scope.launch { 80 | val data = tmdbApi.search(textFieldState.toString()) 81 | results = List(data.size) { index -> data[index].jsonObject }.filter { 82 | (it.contains("name") || it.contains("title")) && (it["media_type"]!!.jsonPrimitive.content == "tv" || it["media_type"]!!.jsonPrimitive.content == "movie") 83 | }.sortedByDescending { 84 | var score = 0.0 85 | if ((it["name"]?.jsonPrimitive?.content ?: it["title"]?.jsonPrimitive?.content ?: "") 86 | .lowercase() == textFieldState.toString().lowercase() 87 | ) { 88 | score = Double.MAX_VALUE 89 | } 90 | if (it.contains("overview") && it["overview"]!!.jsonPrimitive.content 91 | .isNotBlank() 92 | ) { 93 | score += 2 94 | } 95 | if (it.contains("poster_path") && it["poster_path"]!!.jsonPrimitive.content 96 | .isNotBlank() 97 | ) { 98 | score += 1 99 | } 100 | if (it.contains("popularity")) { 101 | score += it["popularity"]!!.jsonPrimitive.double 102 | } 103 | score 104 | } 105 | shouldLoad = false 106 | } 107 | }, 108 | expanded = false, 109 | onExpandedChange = { }, 110 | placeholder = { Text("Search away, matey. Courtesy of TMDB") }, 111 | leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, 112 | 113 | ) 114 | }, modifier = if (topBar) { 115 | Modifier.focusRequester(focusRequester).fillMaxWidth().padding(horizontal = 10.dp) 116 | } else { 117 | Modifier.focusRequester(focusRequester).fillMaxWidth().padding(top = 0.dp, bottom = 0.dp) 118 | }, shape = if (topBar) { 119 | RoundedCornerShape(25.dp, 25.dp, 25.dp, 25.dp) 120 | } else { 121 | RoundedCornerShape(24.dp, 24.dp, 0.dp, 0.dp) 122 | }, expanded = false, onExpandedChange = { }, windowInsets = WindowInsets(top = 0.dp) 123 | ) {} 124 | } 125 | if (topBar) { 126 | LazyColumn( 127 | modifier = Modifier.fillMaxSize().weight(1f) 128 | ) { 129 | items(results) { result -> 130 | if (!result["id"]!!.jsonPrimitive.content.startsWith("/")) { 131 | WatchListItem(result, { page = it }, { meta = it; }) 132 | } 133 | } 134 | } 135 | } 136 | } 137 | if (shouldLoad) { 138 | LoadingScreen() 139 | } 140 | } else { 141 | WatchPage(meta, navController) 142 | } 143 | } 144 | 145 | } -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/dev/ech0/torbox/multiplatform/ui/pages/watch/WatchSearchPageN.kt: -------------------------------------------------------------------------------- 1 | package dev.ech0.torbox.multiplatform.ui.pages.watch 2 | 3 | import androidx.compose.foundation.ExperimentalFoundationApi 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.WindowInsets 6 | import androidx.compose.foundation.layout.fillMaxSize 7 | import androidx.compose.foundation.layout.fillMaxWidth 8 | import androidx.compose.foundation.layout.padding 9 | import androidx.compose.foundation.lazy.LazyColumn 10 | import androidx.compose.foundation.lazy.items 11 | import androidx.compose.foundation.shape.RoundedCornerShape 12 | import androidx.compose.material.icons.Icons 13 | import androidx.compose.material.icons.filled.Search 14 | import androidx.compose.material3.ExperimentalMaterial3Api 15 | import androidx.compose.material3.Icon 16 | import androidx.compose.material3.Scaffold 17 | import androidx.compose.material3.SearchBar 18 | import androidx.compose.material3.SearchBarDefaults 19 | import androidx.compose.material3.Text 20 | import androidx.compose.runtime.Composable 21 | import androidx.compose.runtime.getValue 22 | import androidx.compose.runtime.mutableStateOf 23 | import androidx.compose.runtime.remember 24 | import androidx.compose.runtime.rememberCoroutineScope 25 | import androidx.compose.runtime.setValue 26 | import androidx.compose.ui.ExperimentalComposeUiApi 27 | import androidx.compose.ui.Modifier 28 | import androidx.compose.ui.backhandler.BackHandler 29 | import androidx.compose.ui.focus.FocusRequester 30 | import androidx.compose.ui.focus.focusRequester 31 | import androidx.compose.ui.platform.LocalFocusManager 32 | import androidx.compose.ui.unit.dp 33 | import com.russhwolf.settings.Settings 34 | import dev.ech0.torbox.multiplatform.api.tmdbApi 35 | import dev.ech0.torbox.multiplatform.ui.components.WatchSearchListItemN 36 | import kotlinx.coroutines.launch 37 | import kotlinx.serialization.json.jsonObject 38 | import kotlinx.serialization.json.jsonPrimitive 39 | 40 | 41 | enum class WatchSearchResultType { 42 | MOVIE, TV, ANIME 43 | } 44 | 45 | enum class WatchSearchResultProvider { 46 | TMDB, KITSU 47 | } 48 | 49 | data class WatchSearchResult( 50 | val provider: WatchSearchResultProvider, 51 | val id: Long, 52 | val type: WatchSearchResultType, 53 | val title: String, 54 | val summary: String?, 55 | val poster: String? 56 | ) 57 | 58 | data class Episode( 59 | val title: String, 60 | val number: Int, 61 | val summary: String? = "", 62 | val freezeFrame: String? = "", 63 | ) 64 | 65 | data class Season( 66 | val number: Int, val episodes: List 67 | ) 68 | 69 | data class WatchSearchResultExpanded( 70 | val base: WatchSearchResult, val seasons: List, val cover: String = "" 71 | ) 72 | 73 | @OptIn( 74 | ExperimentalMaterial3Api::class, 75 | ExperimentalFoundationApi::class, 76 | ExperimentalComposeUiApi::class 77 | ) 78 | @Composable 79 | fun WatchSearchPageN() { 80 | var results by remember { mutableStateOf(mutableListOf()) } 81 | val scope = rememberCoroutineScope() 82 | var textFieldState by remember { mutableStateOf("") } 83 | var page by remember { mutableStateOf(1) } 84 | var selectedData by remember { 85 | mutableStateOf( 86 | WatchSearchResult( 87 | WatchSearchResultProvider.TMDB, 88 | 0, 89 | WatchSearchResultType.MOVIE, 90 | "", 91 | "", 92 | "" 93 | ) 94 | ) 95 | } 96 | BackHandler(enabled = page != 1) { 97 | page = 1 98 | } 99 | if (page == 1) { 100 | @OptIn(ExperimentalMaterial3Api::class) 101 | @Composable 102 | fun WatchSearchBar(topBar: Boolean) { 103 | val focusRequester = remember { FocusRequester() } 104 | val focusManager = LocalFocusManager.current 105 | Column { 106 | SearchBar( 107 | inputField = { 108 | SearchBarDefaults.InputField( 109 | query = textFieldState, 110 | onQueryChange = { 111 | textFieldState = it 112 | }, 113 | onSearch = { 114 | focusManager.clearFocus() 115 | scope.launch { 116 | if (textFieldState != "") { 117 | val unsorted = mutableListOf() 118 | tmdbApi.search(textFieldState).forEach { it -> 119 | val data = it.jsonObject 120 | if (data["media_type"]!!.jsonPrimitive.content.uppercase() == "MOVIE" || data["media_type"]!!.jsonPrimitive.content.uppercase() == "TV") { 121 | unsorted.add( 122 | WatchSearchResult( 123 | provider = WatchSearchResultProvider.TMDB, 124 | id = data["id"]!!.jsonPrimitive.content.toLong(), 125 | type = WatchSearchResultType.valueOf(data["media_type"]!!.jsonPrimitive.content.uppercase()), 126 | title = (data["name"]?.jsonPrimitive?.content 127 | ?: data["title"]?.jsonPrimitive?.content 128 | ?: ""), 129 | summary = data["overview"]?.jsonPrimitive?.content, 130 | poster = tmdbApi.imageHelper(it.jsonObject["poster_path"]!!.jsonPrimitive.content), 131 | ) 132 | ) 133 | } 134 | 135 | } 136 | results = unsorted.sortedByDescending { 137 | var score = 0.0 138 | if (it.title.lowercase() == textFieldState.toString() 139 | .lowercase() 140 | ) { 141 | score = Double.MAX_VALUE 142 | } 143 | if (it.title == "") { 144 | score = Double.MIN_VALUE 145 | } 146 | if (it.summary?.isNotBlank() == true) { 147 | score += 2 148 | } 149 | if (it.poster?.isNotBlank() == true) { 150 | score += 1 151 | } 152 | score 153 | }.toMutableList() 154 | } 155 | } 156 | }, 157 | expanded = false, 158 | onExpandedChange = { }, 159 | placeholder = { Text("Search away, matey. Courtesy of TMDB") }, 160 | leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, 161 | 162 | ) 163 | }, 164 | modifier = if (topBar) { 165 | Modifier.focusRequester(focusRequester).fillMaxWidth() 166 | .padding(horizontal = 10.dp) 167 | } else { 168 | Modifier.focusRequester(focusRequester).fillMaxWidth() 169 | .padding(top = 0.dp, bottom = 0.dp) 170 | }, 171 | shape = if (topBar) { 172 | RoundedCornerShape(25.dp, 25.dp, 25.dp, 25.dp) 173 | } else { 174 | RoundedCornerShape(24.dp, 24.dp, 0.dp, 0.dp) 175 | }, 176 | expanded = false, 177 | onExpandedChange = { }, 178 | windowInsets = WindowInsets(top = 0.dp) 179 | ) {} 180 | } 181 | } 182 | 183 | val topBar = Settings().getBoolean("searchTop", false) 184 | Scaffold(topBar = { if (topBar) WatchSearchBar(topBar) }, 185 | bottomBar = { if (!topBar) WatchSearchBar(topBar) }, 186 | modifier = Modifier.fillMaxSize() 187 | ) { innerPadding -> 188 | LazyColumn(modifier = Modifier.fillMaxSize().padding(innerPadding)) { 189 | items(results) { 190 | WatchSearchListItemN(it, onClick = { page = 2; selectedData = it }) 191 | } 192 | } 193 | } 194 | } else if (page == 2) { 195 | WatchPageN(selectedData) 196 | } 197 | 198 | } -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | # SPDX-License-Identifier: Apache-2.0 19 | # 20 | 21 | ############################################################################## 22 | # 23 | # Gradle start up script for POSIX generated by Gradle. 24 | # 25 | # Important for running: 26 | # 27 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 28 | # noncompliant, but you have some other compliant shell such as ksh or 29 | # bash, then to run this script, type that shell name before the whole 30 | # command line, like: 31 | # 32 | # ksh Gradle 33 | # 34 | # Busybox and similar reduced shells will NOT work, because this script 35 | # requires all of these POSIX shell features: 36 | # * functions; 37 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 38 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 39 | # * compound commands having a testable exit status, especially «case»; 40 | # * various built-in commands including «command», «set», and «ulimit». 41 | # 42 | # Important for patching: 43 | # 44 | # (2) This script targets any POSIX shell, so it avoids extensions provided 45 | # by Bash, Ksh, etc; in particular arrays are avoided. 46 | # 47 | # The "traditional" practice of packing multiple parameters into a 48 | # space-separated string is a well documented source of bugs and security 49 | # problems, so this is (mostly) avoided, by progressively accumulating 50 | # options in "$@", and eventually passing that to Java. 51 | # 52 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 53 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 54 | # see the in-line comments for details. 55 | # 56 | # There are tweaks for specific operating systems such as AIX, CygWin, 57 | # Darwin, MinGW, and NonStop. 58 | # 59 | # (3) This script is generated from the Groovy template 60 | # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 61 | # within the Gradle project. 62 | # 63 | # You can find Gradle at https://github.com/gradle/gradle/. 64 | # 65 | ############################################################################## 66 | 67 | # Attempt to set APP_HOME 68 | 69 | # Resolve links: $0 may be a link 70 | app_path=$0 71 | 72 | # Need this for daisy-chained symlinks. 73 | while 74 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 75 | [ -h "$app_path" ] 76 | do 77 | ls=$( ls -ld "$app_path" ) 78 | link=${ls#*' -> '} 79 | case $link in #( 80 | /*) app_path=$link ;; #( 81 | *) app_path=$APP_HOME$link ;; 82 | esac 83 | done 84 | 85 | # This is normally unused 86 | # shellcheck disable=SC2034 87 | APP_BASE_NAME=${0##*/} 88 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 89 | APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s 90 | ' "$PWD" ) || exit 91 | 92 | # Use the maximum available, or set MAX_FD != -1 to use that value. 93 | MAX_FD=maximum 94 | 95 | warn () { 96 | echo "$*" 97 | } >&2 98 | 99 | die () { 100 | echo 101 | echo "$*" 102 | echo 103 | exit 1 104 | } >&2 105 | 106 | # OS specific support (must be 'true' or 'false'). 107 | cygwin=false 108 | msys=false 109 | darwin=false 110 | nonstop=false 111 | case "$( uname )" in #( 112 | CYGWIN* ) cygwin=true ;; #( 113 | Darwin* ) darwin=true ;; #( 114 | MSYS* | MINGW* ) msys=true ;; #( 115 | NONSTOP* ) nonstop=true ;; 116 | esac 117 | 118 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 119 | 120 | 121 | # Determine the Java command to use to start the JVM. 122 | if [ -n "$JAVA_HOME" ] ; then 123 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 124 | # IBM's JDK on AIX uses strange locations for the executables 125 | JAVACMD=$JAVA_HOME/jre/sh/java 126 | else 127 | JAVACMD=$JAVA_HOME/bin/java 128 | fi 129 | if [ ! -x "$JAVACMD" ] ; then 130 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 131 | 132 | Please set the JAVA_HOME variable in your environment to match the 133 | location of your Java installation." 134 | fi 135 | else 136 | JAVACMD=java 137 | if ! command -v java >/dev/null 2>&1 138 | then 139 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 140 | 141 | Please set the JAVA_HOME variable in your environment to match the 142 | location of your Java installation." 143 | fi 144 | fi 145 | 146 | # Increase the maximum file descriptors if we can. 147 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 148 | case $MAX_FD in #( 149 | max*) 150 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 151 | # shellcheck disable=SC2039,SC3045 152 | MAX_FD=$( ulimit -H -n ) || 153 | warn "Could not query maximum file descriptor limit" 154 | esac 155 | case $MAX_FD in #( 156 | '' | soft) :;; #( 157 | *) 158 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 159 | # shellcheck disable=SC2039,SC3045 160 | ulimit -n "$MAX_FD" || 161 | warn "Could not set maximum file descriptor limit to $MAX_FD" 162 | esac 163 | fi 164 | 165 | # Collect all arguments for the java command, stacking in reverse order: 166 | # * args from the command line 167 | # * the main class name 168 | # * -classpath 169 | # * -D...appname settings 170 | # * --module-path (only if needed) 171 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 172 | 173 | # For Cygwin or MSYS, switch paths to Windows format before running java 174 | if "$cygwin" || "$msys" ; then 175 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 176 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 177 | 178 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 179 | 180 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 181 | for arg do 182 | if 183 | case $arg in #( 184 | -*) false ;; # don't mess with options #( 185 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 186 | [ -e "$t" ] ;; #( 187 | *) false ;; 188 | esac 189 | then 190 | arg=$( cygpath --path --ignore --mixed "$arg" ) 191 | fi 192 | # Roll the args list around exactly as many times as the number of 193 | # args, so each arg winds up back in the position where it started, but 194 | # possibly modified. 195 | # 196 | # NB: a `for` loop captures its iteration list before it begins, so 197 | # changing the positional parameters here affects neither the number of 198 | # iterations, nor the values presented in `arg`. 199 | shift # remove old arg 200 | set -- "$@" "$arg" # push replacement arg 201 | done 202 | fi 203 | 204 | 205 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 206 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 207 | 208 | # Collect all arguments for the java command: 209 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 210 | # and any embedded shellness will be escaped. 211 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 212 | # treated as '${Hostname}' itself on the command line. 213 | 214 | set -- \ 215 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 216 | -classpath "$CLASSPATH" \ 217 | org.gradle.wrapper.GradleWrapperMain \ 218 | "$@" 219 | 220 | # Stop when "xargs" is not available. 221 | if ! command -v xargs >/dev/null 2>&1 222 | then 223 | die "xargs is not available" 224 | fi 225 | 226 | # Use "xargs" to parse quoted args. 227 | # 228 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 229 | # 230 | # In Bash we could simply go: 231 | # 232 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 233 | # set -- "${ARGS[@]}" "$@" 234 | # 235 | # but POSIX shell has neither arrays nor command substitution, so instead we 236 | # post-process each arg (as a line of input to sed) to backslash-escape any 237 | # character that might be a shell metacharacter, then use eval to reverse 238 | # that process (while maintaining the separation between arguments), and wrap 239 | # the whole thing up as a single "set" statement. 240 | # 241 | # This will of course break if any of these variables contains a newline or 242 | # an unmatched quote. 243 | # 244 | 245 | eval "set -- $( 246 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 247 | xargs -n1 | 248 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 249 | tr '\n' ' ' 250 | )" '"$@"' 251 | 252 | exec "$JAVACMD" "$@" 253 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/dev/ech0/torbox/multiplatform/ui/components/DownloadItem.kt: -------------------------------------------------------------------------------- 1 | package dev.ech0.torbox.multiplatform.ui.components 2 | 3 | import androidx.compose.foundation.ExperimentalFoundationApi 4 | import androidx.compose.foundation.combinedClickable 5 | import androidx.compose.foundation.layout.* 6 | import androidx.compose.foundation.shape.RoundedCornerShape 7 | import androidx.compose.material.icons.Icons 8 | import androidx.compose.material.icons.automirrored.filled.InsertDriveFile 9 | import androidx.compose.material.icons.filled.* 10 | import androidx.compose.material3.* 11 | import androidx.compose.runtime.* 12 | import androidx.compose.ui.Alignment 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.draw.blur 15 | import androidx.compose.ui.draw.clip 16 | import androidx.compose.ui.hapticfeedback.HapticFeedbackType 17 | import androidx.compose.ui.platform.LocalClipboardManager 18 | import androidx.compose.ui.platform.LocalHapticFeedback 19 | import androidx.compose.ui.platform.LocalUriHandler 20 | import androidx.compose.ui.text.AnnotatedString 21 | import androidx.compose.ui.text.capitalize 22 | import androidx.compose.ui.unit.dp 23 | import androidx.navigation.NavController 24 | import com.russhwolf.settings.Settings 25 | import dev.ech0.torbox.multiplatform.LocalSnackbarHostState 26 | import dev.ech0.torbox.multiplatform.api.torboxAPI 27 | import dev.ech0.torbox.multiplatform.formatFileSize 28 | import dev.ech0.torbox.multiplatform.getPlatform 29 | import io.ktor.http.* 30 | import kotlinx.coroutines.launch 31 | import kotlinx.serialization.Serializable 32 | import kotlinx.serialization.json.jsonObject 33 | import kotlinx.serialization.json.jsonPrimitive 34 | 35 | @OptIn(ExperimentalFoundationApi::class) 36 | @Composable 37 | fun DownloadItem( 38 | download: Download, setLoadingScreen: (Boolean) -> Unit, setRefresh: (Boolean) -> Unit, navController: NavController 39 | ) { 40 | val scope = rememberCoroutineScope() 41 | val uriHandler = LocalUriHandler.current 42 | val haptics = LocalHapticFeedback.current 43 | var expanded by remember { mutableStateOf(false) } 44 | val clipboardManager = LocalClipboardManager.current 45 | val snackbarHostState = LocalSnackbarHostState.current 46 | Row( 47 | modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp, horizontal = 20.dp) 48 | .combinedClickable(onClick = {}, onLongClick = { 49 | haptics.performHapticFeedback(HapticFeedbackType.LongPress) 50 | expanded = true 51 | }), verticalAlignment = Alignment.CenterVertically 52 | ) { 53 | Icon( 54 | if (download.seeds != null) Icons.AutoMirrored.Filled.InsertDriveFile else Icons.Filled.Newspaper, 55 | "File", 56 | modifier = Modifier.padding(end = 8.dp) 57 | ) 58 | Column(modifier = Modifier.weight(1f)) { 59 | Text( 60 | text = download.name, 61 | style = MaterialTheme.typography.titleMedium, 62 | color = MaterialTheme.colorScheme.onSurface, 63 | modifier = if (Settings().getBoolean("blurDL", false)) { 64 | Modifier 65 | .clip(RoundedCornerShape(6.dp)) 66 | .blur(10.dp) 67 | } else { 68 | Modifier 69 | } 70 | ) 71 | Text( 72 | text = "${download.downloadState.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }}, ↓${ 73 | formatFileSize(download.downloadSpeed.toLong()) 74 | }/s${ 75 | if (download.seeds != null) ", ↑${ 76 | formatFileSize(download.uploadSpeed.toLong()) 77 | }/s" else "" 78 | }", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant 79 | ) 80 | 81 | } 82 | fun handleClick(action: String) { 83 | expanded = false 84 | scope.launch { 85 | setLoadingScreen(true) 86 | try { 87 | if (download.seeds != null) { 88 | torboxAPI.controlTorrent(download.id, action) 89 | } else { 90 | torboxAPI.controlUsenet(download.id, action) 91 | } 92 | } catch (e: Exception) { 93 | navController.navigate("Error/${e.toString().encodeURLPath()}") 94 | } 95 | setLoadingScreen(false) 96 | setRefresh(true) 97 | } 98 | } 99 | DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { 100 | val dropdownItems = if (download.seeds != null) { 101 | listOf( 102 | Triple("Reannounce", Icons.Filled.Campaign, "reannounce"), 103 | Triple("Resume", Icons.Filled.PlayArrow, "resume"), 104 | Triple("Delete", Icons.Filled.Delete, "delete") 105 | ) 106 | } else { 107 | listOf( 108 | Triple("Pause", Icons.Filled.Pause, "pause"), 109 | Triple("Resume", Icons.Filled.PlayArrow, "resume"), 110 | Triple("Delete", Icons.Filled.Delete, "delete") 111 | ) 112 | } 113 | dropdownItems.forEach { (text, icon, action) -> 114 | DropdownMenuItem( 115 | onClick = { handleClick(action) }, 116 | text = { Text(text) }, 117 | leadingIcon = { Icon(icon, text) }) 118 | } 119 | } 120 | if (download.cached) { 121 | IconButtonLongClickable( 122 | onClick = { 123 | scope.launch { 124 | if(!getPlatform().name.contains("Java")){ 125 | haptics.performHapticFeedback(HapticFeedbackType.LongPress) 126 | } 127 | setLoadingScreen(true) 128 | try { 129 | if (download.seeds != null) { 130 | uriHandler.openUri( 131 | torboxAPI.getTorrentLink( 132 | download.id, download.files.size > 1 133 | )["data"]!!.jsonPrimitive.content 134 | ) 135 | } else { 136 | uriHandler.openUri( 137 | torboxAPI.getUsenetLink( 138 | download.id, false 139 | )["data"]!!.jsonPrimitive.content 140 | ) 141 | } 142 | } catch (e: Exception) { 143 | navController.navigate("Error/${e.toString().encodeURLPath()}") 144 | } 145 | setLoadingScreen(false) 146 | } 147 | }, onLongClick = { 148 | scope.launch { 149 | setLoadingScreen(true) 150 | try { 151 | if (download.seeds != null) { 152 | clipboardManager.setText( 153 | AnnotatedString( 154 | torboxAPI.getTorrentLink( 155 | download.id, download.files.size > 1 156 | )["data"]!!.jsonPrimitive.content 157 | ) 158 | ) 159 | } else { 160 | clipboardManager.setText( 161 | AnnotatedString( 162 | torboxAPI.getUsenetLink( 163 | download.id, false 164 | )["data"]!!.jsonPrimitive.content 165 | ) 166 | ) 167 | } 168 | } catch (e: Exception) { 169 | navController.navigate("Error/${e.toString().encodeURLPath()}") 170 | } 171 | setLoadingScreen(false) 172 | snackbarHostState.showSnackbar("Copied!") 173 | } 174 | 175 | }, modifier = Modifier.padding(start = 16.dp).size(32.dp), content = { 176 | Icon( 177 | Icons.Filled.Download, contentDescription = "download", modifier = Modifier.padding(4.dp) 178 | ) 179 | }, colors = IconButtonColors( 180 | containerColor = MaterialTheme.colorScheme.secondaryContainer, 181 | contentColor = MaterialTheme.colorScheme.onSecondaryContainer, 182 | disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerHighest, 183 | disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant 184 | ) 185 | ) 186 | } else { 187 | if (download.downloadState.startsWith("error")) { 188 | Icon( 189 | Icons.Filled.ErrorOutline, 190 | "Error", 191 | modifier = Modifier.size(24.dp), 192 | tint = MaterialTheme.colorScheme.tertiary 193 | ) 194 | } else if (download.progress == 0.0 || download.downloadState.startsWith("stalled") || download.downloadState.startsWith( 195 | "checkingDL" 196 | ) 197 | ) { 198 | CircularProgressIndicator( 199 | modifier = Modifier.padding(start = 16.dp).size(24.dp) 200 | ) 201 | } else { 202 | CircularProgressIndicator( 203 | progress = { 204 | download.progress.toFloat() 205 | }, 206 | modifier = Modifier.padding(start = 16.dp).size(24.dp), 207 | trackColor = ProgressIndicatorDefaults.circularIndeterminateTrackColor, 208 | ) 209 | } 210 | 211 | } 212 | } 213 | 214 | HorizontalDivider(modifier = Modifier.padding(horizontal = 20.dp)) 215 | } 216 | 217 | @Serializable 218 | data class Download( 219 | val id: Int, 220 | val name: String, 221 | val downloadState: String, 222 | val downloadSpeed: Double, 223 | val uploadSpeed: Double = 0.0, 224 | val progress: Double, 225 | val cached: Boolean, 226 | val seeds: Int? = null, 227 | val files: List 228 | ) 229 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/dev/ech0/torbox/multiplatform/api/Trakt.kt: -------------------------------------------------------------------------------- 1 | package dev.ech0.torbox.multiplatform.api 2 | 3 | import com.russhwolf.settings.Settings 4 | import dev.ech0.torbox.multiplatform.BuildConfig 5 | import io.ktor.client.HttpClient 6 | import io.ktor.client.request.get 7 | import io.ktor.client.request.headers 8 | import io.ktor.client.request.post 9 | import io.ktor.client.request.setBody 10 | import io.ktor.client.statement.bodyAsText 11 | import io.ktor.http.HttpHeaders 12 | import kotlinx.coroutines.Dispatchers 13 | import kotlinx.coroutines.GlobalScope 14 | import kotlinx.coroutines.IO 15 | import kotlinx.coroutines.delay 16 | import kotlinx.coroutines.flow.flow 17 | import kotlinx.coroutines.flow.retry 18 | import kotlinx.coroutines.flow.single 19 | import kotlinx.coroutines.launch 20 | import kotlinx.datetime.Clock 21 | import kotlinx.io.IOException 22 | import kotlinx.serialization.json.Json 23 | import kotlinx.serialization.json.JsonObject 24 | import kotlinx.serialization.json.JsonPrimitive 25 | import kotlinx.serialization.json.int 26 | import kotlinx.serialization.json.jsonArray 27 | import kotlinx.serialization.json.jsonObject 28 | import kotlinx.serialization.json.jsonPrimitive 29 | 30 | lateinit var traktApi: Trakt 31 | 32 | class Trakt { 33 | private val base = "https://api.trakt.tv/" 34 | 35 | private val ktor = HttpClient {} 36 | 37 | private lateinit var token: String 38 | private var lastConnect = 0L 39 | private suspend fun throttle() { 40 | val now = Clock.System.now().toEpochMilliseconds() 41 | if (now - lastConnect < 1000) { 42 | delay(1000 - (now - lastConnect)) 43 | } 44 | lastConnect = Clock.System.now().toEpochMilliseconds() 45 | } 46 | 47 | private var loggedIn = false 48 | 49 | init { 50 | if (Settings().getString("traktToken", "") != "") { 51 | GlobalScope.launch(Dispatchers.IO) { 52 | getAccToken().let { 53 | if (it.contains("error")) { 54 | delay(2000) 55 | traktApi = Trakt() 56 | } else { 57 | token = it["access_token"]!!.jsonPrimitive.content 58 | Settings().putString( 59 | "traktToken", 60 | it["refresh_token"]!!.jsonPrimitive.content 61 | ) 62 | loggedIn = true 63 | } 64 | } 65 | } 66 | } else { 67 | loggedIn = false 68 | } 69 | 70 | } 71 | 72 | suspend fun getAuthCode(): JsonObject { 73 | throttle() 74 | val response = ktor.post(base + "oauth/device/code") { 75 | setBody( 76 | JsonObject( 77 | mapOf( 78 | Pair( 79 | "client_id", 80 | JsonPrimitive(BuildConfig.TRAKT_KEY) 81 | ) 82 | ) 83 | ).toString() 84 | ) 85 | headers { 86 | append(HttpHeaders.ContentType, "application/json") 87 | } 88 | } 89 | val json = Json.parseToJsonElement(response.bodyAsText()).jsonObject 90 | Json.parseToJsonElement(response.bodyAsText()).jsonObject 91 | return json 92 | } 93 | 94 | suspend fun getRefreshToken(code: String): String { 95 | throttle() 96 | val response = ktor.post(base + "oauth/device/token") { 97 | setBody( 98 | JsonObject( 99 | mapOf( 100 | Pair("client_id", JsonPrimitive(BuildConfig.TRAKT_KEY)), 101 | Pair("client_secret", JsonPrimitive(BuildConfig.TRAKT_SECRET)), 102 | Pair("code", JsonPrimitive(code)) 103 | ) 104 | ).toString() 105 | ) 106 | headers { 107 | append(HttpHeaders.ContentType, "application/json") 108 | } 109 | } 110 | if (response.status.value == 200) { 111 | val json = Json.parseToJsonElement(response.bodyAsText()).jsonObject 112 | return json["refresh_token"]!!.jsonPrimitive.content 113 | } else if (response.status.value == 400) { 114 | return "" 115 | } else { 116 | throw IOException("Trakt API Error") 117 | } 118 | } 119 | 120 | private suspend fun getAccToken(): JsonObject { 121 | throttle() 122 | val refreshToken = Settings().getString("traktToken", "") 123 | if (refreshToken != "") { 124 | val result: JsonObject = flow { 125 | val response = ktor.post(base + "oauth/token") { 126 | setBody( 127 | JsonObject( 128 | mapOf( 129 | Pair("client_id", JsonPrimitive(BuildConfig.TRAKT_KEY)), 130 | Pair("client_secret", JsonPrimitive(BuildConfig.TRAKT_SECRET)), 131 | Pair("refresh_token", JsonPrimitive(refreshToken)), 132 | Pair("redirect_uri", JsonPrimitive("urn:ietf:wg:oauth:2.0:oob")), 133 | Pair("grant_type", JsonPrimitive("refresh_token")) 134 | ) 135 | ).toString() 136 | ) 137 | headers { 138 | append(HttpHeaders.ContentType, "application/json") 139 | } 140 | } 141 | val json = Json.parseToJsonElement(response.bodyAsText()).jsonObject 142 | emit(json) 143 | }.retry(5) { 144 | delay(1000) 145 | true 146 | }.single() 147 | return result 148 | } else { 149 | return Json.parseToJsonElement("{}").jsonObject 150 | } 151 | } 152 | 153 | suspend fun addShow(id: Long, season: Int, episode: Int) { 154 | if (loggedIn) { 155 | throttle() 156 | val response = ktor.post(base + "sync/history") { 157 | headers { 158 | append(HttpHeaders.ContentType, "application/json") 159 | append(HttpHeaders.Authorization, "Bearer $token") 160 | append("trakt-api-key", BuildConfig.TRAKT_KEY) 161 | append("trakt-api-version", "2") 162 | } 163 | setBody( 164 | Json.parseToJsonElement( 165 | """ 166 | { 167 | "shows": [ 168 | { 169 | "ids": { 170 | "tmdb": $id 171 | }, 172 | "seasons": [ 173 | { 174 | "number": $season, 175 | "episodes": [ 176 | { 177 | "number": $episode 178 | } 179 | ] 180 | } 181 | ] 182 | } 183 | ] 184 | } 185 | """.trimIndent() 186 | ).jsonObject.toString() 187 | ) 188 | } 189 | val json = Json.parseToJsonElement(response.bodyAsText()).jsonObject 190 | } 191 | } 192 | 193 | suspend fun addMovie(id: Long) { 194 | if (loggedIn) { 195 | throttle() 196 | val response = ktor.post(base + "sync/history") { 197 | headers { 198 | append(HttpHeaders.ContentType, "application/json") 199 | append(HttpHeaders.Authorization, "Bearer $token") 200 | append("trakt-api-key", BuildConfig.TRAKT_KEY) 201 | append("trakt-api-version", "2") 202 | } 203 | setBody( 204 | Json.parseToJsonElement( 205 | """ 206 | { 207 | "movies": [ 208 | { 209 | "ids": { 210 | "tmdb": $id 211 | } 212 | } 213 | ] 214 | } 215 | """.trimIndent() 216 | ).jsonObject.toString() 217 | ) 218 | } 219 | val json = Json.parseToJsonElement(response.bodyAsText()).jsonObject 220 | } 221 | } 222 | 223 | suspend fun removeShow(id: Long, season: Int, episode: Int) { 224 | if (loggedIn) { 225 | throttle() 226 | val response = ktor.post(base + "sync/history/remove") { 227 | headers { 228 | append(HttpHeaders.ContentType, "application/json") 229 | append(HttpHeaders.Authorization, "Bearer $token") 230 | append("trakt-api-key", BuildConfig.TRAKT_KEY) 231 | append("trakt-api-version", "2") 232 | } 233 | setBody( 234 | Json.parseToJsonElement( 235 | """ 236 | { 237 | "shows": [ 238 | { 239 | "ids": { 240 | "tmdb": $id 241 | }, 242 | "seasons": [ 243 | { 244 | "number": $season, 245 | "episodes": [ 246 | { 247 | "number": $episode 248 | } 249 | ] 250 | } 251 | ] 252 | } 253 | ] 254 | } 255 | """.trimIndent() 256 | ).jsonObject.toString() 257 | ) 258 | } 259 | val json = Json.parseToJsonElement(response.bodyAsText()).jsonObject 260 | } 261 | } 262 | 263 | suspend fun removeMovie(id: Long) { 264 | if (loggedIn) { 265 | throttle() 266 | val response = ktor.post(base + "sync/history/remove") { 267 | headers { 268 | append(HttpHeaders.ContentType, "application/json") 269 | append(HttpHeaders.Authorization, "Bearer $token") 270 | append("trakt-api-key", BuildConfig.TRAKT_KEY) 271 | append("trakt-api-version", "2") 272 | } 273 | setBody( 274 | Json.parseToJsonElement( 275 | """ 276 | { 277 | "movies": [ 278 | { 279 | "ids": { 280 | "tmdb": $id 281 | } 282 | } 283 | ] 284 | } 285 | """.trimIndent() 286 | ).jsonObject.toString() 287 | ) 288 | } 289 | val json = Json.parseToJsonElement(response.bodyAsText()).jsonObject 290 | } 291 | } 292 | 293 | suspend fun getWatchedShow(traktId: Long): JsonObject? { 294 | if (loggedIn) { 295 | throttle() 296 | val response = ktor.get(base + "sync/history/shows/$traktId") { 297 | headers { 298 | append(HttpHeaders.ContentType, "application/json") 299 | append(HttpHeaders.Authorization, "Bearer $token") 300 | append("trakt-api-key", BuildConfig.TRAKT_KEY) 301 | append("trakt-api-version", "2") 302 | } 303 | } 304 | try { 305 | val json = Json.parseToJsonElement(response.bodyAsText()).jsonArray 306 | return JsonObject(mapOf(Pair("data", json))) 307 | } catch (e: Exception) { 308 | return null 309 | } 310 | } else { 311 | return null 312 | } 313 | } 314 | 315 | suspend fun getWatchedMovie(traktId: Long): JsonObject? { 316 | if (loggedIn) { 317 | throttle() 318 | val response = ktor.get(base + "sync/history/movies/$traktId") { 319 | headers { 320 | append(HttpHeaders.ContentType, "application/json") 321 | append(HttpHeaders.Authorization, "Bearer $token") 322 | append("trakt-api-key", BuildConfig.TRAKT_KEY) 323 | append("trakt-api-version", "2") 324 | } 325 | } 326 | 327 | try { 328 | val json = Json.parseToJsonElement(response.bodyAsText()).jsonArray 329 | return if (json.size == 1) { 330 | json[0].jsonObject 331 | } else { 332 | null 333 | } 334 | } catch (e: Exception) { 335 | return null 336 | } 337 | } else { 338 | return null 339 | } 340 | } 341 | 342 | suspend fun getTraktIdFromTMDB(id: Long): Int { 343 | if (loggedIn) { 344 | throttle() 345 | val response = ktor.get(base + "search/tmdb/$id") { 346 | headers { 347 | append(HttpHeaders.ContentType, "application/json") 348 | append(HttpHeaders.Authorization, "Bearer $token") 349 | append("trakt-api-key", BuildConfig.TRAKT_KEY) 350 | append("trakt-api-version", "2") 351 | } 352 | } 353 | val json = Json.parseToJsonElement(response.bodyAsText()).jsonArray[0].jsonObject 354 | return json[json["type"]!!.jsonPrimitive.content]!!.jsonObject["ids"]!!.jsonObject["trakt"]!!.jsonPrimitive.int 355 | } else { 356 | return 0 357 | } 358 | } 359 | } -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/dev/ech0/torbox/multiplatform/ui/pages/DownloadsPage.kt: -------------------------------------------------------------------------------- 1 | package dev.ech0.torbox.multiplatform.ui.pages 2 | 3 | import androidx.compose.animation.Crossfade 4 | import androidx.compose.animation.ExperimentalAnimationApi 5 | import androidx.compose.foundation.ExperimentalFoundationApi 6 | import androidx.compose.foundation.horizontalScroll 7 | import androidx.compose.foundation.layout.Column 8 | import androidx.compose.foundation.layout.Row 9 | import androidx.compose.foundation.layout.fillMaxSize 10 | import androidx.compose.foundation.layout.fillMaxWidth 11 | import androidx.compose.foundation.layout.height 12 | import androidx.compose.foundation.layout.padding 13 | import androidx.compose.foundation.lazy.LazyColumn 14 | import androidx.compose.foundation.lazy.items 15 | import androidx.compose.foundation.rememberScrollState 16 | import androidx.compose.foundation.verticalScroll 17 | import androidx.compose.material.icons.Icons 18 | import androidx.compose.material.icons.automirrored.filled.InsertDriveFile 19 | import androidx.compose.material.icons.filled.Add 20 | import androidx.compose.material.icons.filled.Check 21 | import androidx.compose.material.icons.filled.Link 22 | import androidx.compose.material.icons.filled.Newspaper 23 | import androidx.compose.material3.Button 24 | import androidx.compose.material3.Card 25 | import androidx.compose.material3.DropdownMenu 26 | import androidx.compose.material3.DropdownMenuItem 27 | import androidx.compose.material3.ExperimentalMaterial3Api 28 | import androidx.compose.material3.FilterChip 29 | import androidx.compose.material3.FilterChipDefaults 30 | import androidx.compose.material3.FloatingActionButton 31 | import androidx.compose.material3.Icon 32 | import androidx.compose.material3.MaterialTheme 33 | import androidx.compose.material3.OutlinedTextField 34 | import androidx.compose.material3.Text 35 | import androidx.compose.material3.VerticalDivider 36 | import androidx.compose.material3.pulltorefresh.PullToRefreshBox 37 | import androidx.compose.runtime.Composable 38 | import androidx.compose.runtime.LaunchedEffect 39 | import androidx.compose.runtime.getValue 40 | import androidx.compose.runtime.mutableStateOf 41 | import androidx.compose.runtime.remember 42 | import androidx.compose.runtime.rememberCoroutineScope 43 | import androidx.compose.runtime.setValue 44 | import androidx.compose.ui.Alignment 45 | import androidx.compose.ui.ExperimentalComposeUiApi 46 | import androidx.compose.ui.Modifier 47 | import androidx.compose.ui.draw.blur 48 | import androidx.compose.ui.unit.dp 49 | import androidx.compose.ui.window.Dialog 50 | import dev.ech0.torbox.multiplatform.LocalNavController 51 | import dev.ech0.torbox.multiplatform.api.torboxAPI 52 | import dev.ech0.torbox.multiplatform.ui.components.DisplayError 53 | import dev.ech0.torbox.multiplatform.ui.components.Download 54 | import dev.ech0.torbox.multiplatform.ui.components.DownloadItem 55 | import dev.ech0.torbox.multiplatform.ui.components.LoadingScreen 56 | import io.ktor.http.encodeURLPath 57 | import kotlinx.coroutines.delay 58 | import kotlinx.coroutines.launch 59 | import kotlinx.datetime.Clock 60 | import kotlinx.datetime.Instant 61 | import kotlinx.serialization.ExperimentalSerializationApi 62 | import kotlinx.serialization.json.JsonObject 63 | import kotlinx.serialization.json.boolean 64 | import kotlinx.serialization.json.buildJsonArray 65 | import kotlinx.serialization.json.double 66 | import kotlinx.serialization.json.intOrNull 67 | import kotlinx.serialization.json.jsonArray 68 | import kotlinx.serialization.json.jsonObject 69 | import kotlinx.serialization.json.jsonPrimitive 70 | 71 | 72 | @OptIn( 73 | ExperimentalMaterial3Api::class, 74 | ExperimentalFoundationApi::class, 75 | ExperimentalComposeUiApi::class, 76 | ExperimentalAnimationApi::class, 77 | ExperimentalSerializationApi::class 78 | ) 79 | @Composable 80 | fun DownloadsPage(magnet: String = "") { 81 | val navController = LocalNavController.current 82 | val scope = rememberCoroutineScope() 83 | var isRefreshing by remember { mutableStateOf(true) } 84 | var downloads by remember { mutableStateOf>(emptyList()) } 85 | var shouldLoad by remember { mutableStateOf(false) } 86 | var error by remember { mutableStateOf(false) } 87 | var expandedFab by remember { mutableStateOf(false) } 88 | var magnetPrompt by remember { mutableStateOf(false) } 89 | var magnetText by remember { mutableStateOf("") } 90 | var selectedUri by remember { mutableStateOf("") } 91 | var currentFilter by remember { mutableStateOf(1) } 92 | var openInPrompted by remember { mutableStateOf(false) }/*TODO: val torrentLoader = rememberLauncherForActivityResult(contract = ActivityResultContracts.GetContent()) { result -> 93 | try { 94 | val item = context.contentResolver.openInputStream(result!!) 95 | val bytes = item!!.readBytes() 96 | scope.launch { 97 | shouldLoad = true 98 | if(context.contentResolver.getType(result) == "application/x-bittorrent"){ 99 | torboxAPI.createTorrent(bytes) 100 | }else{ 101 | torboxAPI.createUsenet(bytes) 102 | 103 | } 104 | shouldLoad = false 105 | isRefreshing = true 106 | } 107 | item.close() 108 | } catch (_: NullPointerException) { 109 | 110 | } 111 | }*/ 112 | LaunchedEffect(Unit) { 113 | while (true) { 114 | val startTime = Clock.System.now().toEpochMilliseconds() 115 | while (!isRefreshing && Clock.System.now().toEpochMilliseconds() - startTime < 1000) { 116 | delay(50L) 117 | } 118 | try { 119 | val data_torrents = torboxAPI.getListOfTorrents()["data"]!!.jsonArray 120 | val data_usenet = torboxAPI.getListOfUsenet()["data"]!!.jsonArray 121 | val data = buildJsonArray { 122 | addAll(data_torrents) 123 | addAll(data_usenet) 124 | } 125 | downloads = data.map { it.jsonObject } 126 | error = false 127 | isRefreshing = false 128 | } catch (e: Exception) { 129 | if (isRefreshing) { 130 | isRefreshing = false 131 | error = true 132 | } 133 | } 134 | }/*if(magnet != "" && !openInPrompted){ 135 | magnetPrompt = true 136 | magnetText = Uri.parse(magnet).toString() 137 | openInPrompted = true 138 | }*/ 139 | } 140 | 141 | 142 | PullToRefreshBox( 143 | isRefreshing = isRefreshing, onRefresh = { 144 | isRefreshing = true 145 | }, modifier = if (shouldLoad) { 146 | Modifier.blur(25.dp) 147 | } else { 148 | Modifier 149 | } 150 | ) { 151 | Column(modifier = Modifier.fillMaxSize()) { 152 | Row( 153 | modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp) 154 | .horizontalScroll(rememberScrollState()), 155 | verticalAlignment = Alignment.CenterVertically 156 | ) { 157 | 158 | FilterChip(currentFilter == 1, label = { 159 | Text("Time Created") 160 | }, onClick = { currentFilter = 1 }, leadingIcon = { 161 | Crossfade(targetState = currentFilter == 1) { state -> 162 | if (state) { 163 | Icon(Icons.Filled.Check, "Checked") 164 | } else { 165 | Icon(Icons.Filled.Add, "Add") 166 | } 167 | } 168 | }, modifier = Modifier.padding(horizontal = 4.dp)) 169 | FilterChip(currentFilter == 0, label = { 170 | Text("Alphabetical") 171 | }, onClick = { currentFilter = 0 }, leadingIcon = { 172 | Crossfade(targetState = currentFilter == 0) { state -> 173 | if (state) { 174 | Icon(Icons.Filled.Check, "Checked") 175 | } else { 176 | Icon(Icons.Filled.Add, "Add") 177 | } 178 | } 179 | }, modifier = Modifier.padding(horizontal = 4.dp)) 180 | VerticalDivider( 181 | modifier = Modifier.height(FilterChipDefaults.Height).padding(4.dp) 182 | ) 183 | } 184 | if (error) { 185 | DisplayError(recoverable = true) 186 | } else { 187 | LazyColumn( 188 | modifier = Modifier.fillMaxSize().padding(bottom = 8.dp, top = 8.dp) 189 | ) { 190 | items(when (currentFilter) { 191 | 0 -> { 192 | downloads.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it["name"]!!.jsonPrimitive.content }) 193 | } 194 | 195 | 1 -> { 196 | downloads.sortedByDescending { 197 | Instant.parse(it["created_at"]!!.jsonPrimitive.content) 198 | .toEpochMilliseconds() 199 | } 200 | } 201 | 202 | else -> { 203 | downloads 204 | } 205 | }) { download -> 206 | DownloadItem(download = Download( 207 | id = download["id"]?.jsonPrimitive?.intOrNull ?: 0, 208 | name = download["name"]?.jsonPrimitive?.content ?: "Unknown", 209 | cached = download["cached"]?.jsonPrimitive?.boolean ?: false, 210 | downloadState = download["download_state"]?.jsonPrimitive?.content 211 | ?: "", 212 | downloadSpeed = download["download_speed"]?.jsonPrimitive?.double 213 | ?: 0.0, 214 | uploadSpeed = download["upload_speed"]?.jsonPrimitive?.double ?: 0.0, 215 | progress = download["progress"]?.jsonPrimitive?.double ?: 0.0, 216 | seeds = download["seeds"]?.jsonPrimitive?.intOrNull, 217 | files = listOf() 218 | ), 219 | setLoadingScreen = { shouldLoad = it }, 220 | setRefresh = { isRefreshing = it }, 221 | navController 222 | ) 223 | } 224 | 225 | } 226 | } 227 | } 228 | FloatingActionButton( 229 | onClick = { expandedFab = true }, 230 | modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp) 231 | ) { 232 | Icon(Icons.Filled.Add, "Add") 233 | DropdownMenu(expanded = expandedFab, onDismissRequest = { expandedFab = false }) { 234 | DropdownMenuItem(onClick = { 235 | expandedFab = false 236 | //torrentLoader.launch("application/x-bittorrent") 237 | }, text = { 238 | Text("Add torrent") 239 | }, leadingIcon = { 240 | Icon(Icons.AutoMirrored.Filled.InsertDriveFile, "Add torrent") 241 | }) 242 | DropdownMenuItem(onClick = { 243 | expandedFab = false 244 | magnetPrompt = true 245 | }, text = { 246 | Text("Add magnet") 247 | }, leadingIcon = { 248 | Icon(Icons.Filled.Link, "Add magnet") 249 | }) 250 | DropdownMenuItem(onClick = { 251 | expandedFab = false 252 | //torrentLoader.launch("application/octet-stream") 253 | }, text = { 254 | Text("Add NZB") 255 | }, leadingIcon = { 256 | Icon(Icons.Filled.Newspaper, "Add NZB") 257 | }) 258 | 259 | } 260 | } 261 | 262 | if (magnetPrompt) { 263 | Dialog(onDismissRequest = { magnetPrompt = false }) { 264 | Card( 265 | modifier = Modifier.fillMaxWidth().padding(16.dp), 266 | 267 | ) { 268 | Column( 269 | modifier = Modifier.padding(24.dp), 270 | horizontalAlignment = Alignment.CenterHorizontally 271 | ) { 272 | Icon( 273 | Icons.Filled.Link, 274 | "Input Magnet URI", 275 | modifier = Modifier.padding(bottom = 0.dp) 276 | ) 277 | Text( 278 | "Input magnet uri", 279 | style = MaterialTheme.typography.titleLarge, 280 | modifier = Modifier.padding(top = 16.dp) 281 | ) 282 | OutlinedTextField( 283 | modifier = Modifier.padding(top = 24.dp, bottom = 24.dp).verticalScroll(rememberScrollState()), 284 | value = magnetText, 285 | onValueChange = { newVal -> 286 | magnetText = newVal 287 | 288 | }, 289 | 290 | ) 291 | Button(onClick = { 292 | scope.launch { 293 | try { 294 | magnetPrompt = false 295 | shouldLoad = true 296 | torboxAPI.createTorrent(magnetText) 297 | magnetText = "" 298 | shouldLoad = false 299 | } catch (e: Exception) { 300 | navController.navigate("Error/${e.toString().encodeURLPath()}") 301 | 302 | } 303 | } 304 | }) { 305 | Text("Submit") 306 | } 307 | } 308 | } 309 | } 310 | 311 | } 312 | } 313 | if (shouldLoad) { 314 | LoadingScreen() 315 | } 316 | } -------------------------------------------------------------------------------- /iosApp/iosApp.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 56; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 058557BB273AAA24004C7B11 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 058557BA273AAA24004C7B11 /* Assets.xcassets */; }; 11 | 058557D9273AAEEB004C7B11 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 058557D8273AAEEB004C7B11 /* Preview Assets.xcassets */; }; 12 | 2152FB042600AC8F00CF470E /* iOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2152FB032600AC8F00CF470E /* iOSApp.swift */; }; 13 | 7555FF83242A565900829871 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7555FF82242A565900829871 /* ContentView.swift */; }; 14 | /* End PBXBuildFile section */ 15 | 16 | /* Begin PBXFileReference section */ 17 | 058557BA273AAA24004C7B11 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 18 | 058557D8273AAEEB004C7B11 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 19 | 2152FB032600AC8F00CF470E /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = ""; }; 20 | 7555FF7B242A565900829871 /* UTAC.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = UTAC.app; sourceTree = BUILT_PRODUCTS_DIR; }; 21 | 7555FF82242A565900829871 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 22 | 7555FF8C242A565B00829871 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 23 | AB3632DC29227652001CCB65 /* Config.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = ""; }; 24 | /* End PBXFileReference section */ 25 | 26 | /* Begin PBXFrameworksBuildPhase section */ 27 | B92378962B6B1156000C7307 /* Frameworks */ = { 28 | isa = PBXFrameworksBuildPhase; 29 | buildActionMask = 2147483647; 30 | files = ( 31 | ); 32 | runOnlyForDeploymentPostprocessing = 0; 33 | }; 34 | /* End PBXFrameworksBuildPhase section */ 35 | 36 | /* Begin PBXGroup section */ 37 | 058557D7273AAEEB004C7B11 /* Preview Content */ = { 38 | isa = PBXGroup; 39 | children = ( 40 | 058557D8273AAEEB004C7B11 /* Preview Assets.xcassets */, 41 | ); 42 | path = "Preview Content"; 43 | sourceTree = ""; 44 | }; 45 | 42799AB246E5F90AF97AA0EF /* Frameworks */ = { 46 | isa = PBXGroup; 47 | children = ( 48 | ); 49 | name = Frameworks; 50 | sourceTree = ""; 51 | }; 52 | 7555FF72242A565900829871 = { 53 | isa = PBXGroup; 54 | children = ( 55 | AB1DB47929225F7C00F7AF9C /* Configuration */, 56 | 7555FF7D242A565900829871 /* iosApp */, 57 | 7555FF7C242A565900829871 /* Products */, 58 | 42799AB246E5F90AF97AA0EF /* Frameworks */, 59 | ); 60 | sourceTree = ""; 61 | }; 62 | 7555FF7C242A565900829871 /* Products */ = { 63 | isa = PBXGroup; 64 | children = ( 65 | 7555FF7B242A565900829871 /* UTAC.app */, 66 | ); 67 | name = Products; 68 | sourceTree = ""; 69 | }; 70 | 7555FF7D242A565900829871 /* iosApp */ = { 71 | isa = PBXGroup; 72 | children = ( 73 | 058557BA273AAA24004C7B11 /* Assets.xcassets */, 74 | 7555FF82242A565900829871 /* ContentView.swift */, 75 | 7555FF8C242A565B00829871 /* Info.plist */, 76 | 2152FB032600AC8F00CF470E /* iOSApp.swift */, 77 | 058557D7273AAEEB004C7B11 /* Preview Content */, 78 | ); 79 | path = iosApp; 80 | sourceTree = ""; 81 | }; 82 | AB1DB47929225F7C00F7AF9C /* Configuration */ = { 83 | isa = PBXGroup; 84 | children = ( 85 | AB3632DC29227652001CCB65 /* Config.xcconfig */, 86 | ); 87 | path = Configuration; 88 | sourceTree = ""; 89 | }; 90 | /* End PBXGroup section */ 91 | 92 | /* Begin PBXNativeTarget section */ 93 | 7555FF7A242A565900829871 /* iosApp */ = { 94 | isa = PBXNativeTarget; 95 | buildConfigurationList = 7555FFA5242A565B00829871 /* Build configuration list for PBXNativeTarget "iosApp" */; 96 | buildPhases = ( 97 | F36B1CEB2AD83DDC00CB74D5 /* Compile Kotlin Framework */, 98 | 7555FF77242A565900829871 /* Sources */, 99 | B92378962B6B1156000C7307 /* Frameworks */, 100 | 7555FF79242A565900829871 /* Resources */, 101 | ); 102 | buildRules = ( 103 | ); 104 | dependencies = ( 105 | ); 106 | name = iosApp; 107 | packageProductDependencies = ( 108 | ); 109 | productName = iosApp; 110 | productReference = 7555FF7B242A565900829871 /* UTAC.app */; 111 | productType = "com.apple.product-type.application"; 112 | }; 113 | /* End PBXNativeTarget section */ 114 | 115 | /* Begin PBXProject section */ 116 | 7555FF73242A565900829871 /* Project object */ = { 117 | isa = PBXProject; 118 | attributes = { 119 | BuildIndependentTargetsInParallel = YES; 120 | LastSwiftUpdateCheck = 1130; 121 | LastUpgradeCheck = 1540; 122 | ORGANIZATIONNAME = orgName; 123 | TargetAttributes = { 124 | 7555FF7A242A565900829871 = { 125 | CreatedOnToolsVersion = 11.3.1; 126 | }; 127 | }; 128 | }; 129 | buildConfigurationList = 7555FF76242A565900829871 /* Build configuration list for PBXProject "iosApp" */; 130 | compatibilityVersion = "Xcode 14.0"; 131 | developmentRegion = en; 132 | hasScannedForEncodings = 0; 133 | knownRegions = ( 134 | en, 135 | Base, 136 | ); 137 | mainGroup = 7555FF72242A565900829871; 138 | packageReferences = ( 139 | ); 140 | productRefGroup = 7555FF7C242A565900829871 /* Products */; 141 | projectDirPath = ""; 142 | projectRoot = ""; 143 | targets = ( 144 | 7555FF7A242A565900829871 /* iosApp */, 145 | ); 146 | }; 147 | /* End PBXProject section */ 148 | 149 | /* Begin PBXResourcesBuildPhase section */ 150 | 7555FF79242A565900829871 /* Resources */ = { 151 | isa = PBXResourcesBuildPhase; 152 | buildActionMask = 2147483647; 153 | files = ( 154 | 058557D9273AAEEB004C7B11 /* Preview Assets.xcassets in Resources */, 155 | 058557BB273AAA24004C7B11 /* Assets.xcassets in Resources */, 156 | ); 157 | runOnlyForDeploymentPostprocessing = 0; 158 | }; 159 | /* End PBXResourcesBuildPhase section */ 160 | 161 | /* Begin PBXShellScriptBuildPhase section */ 162 | F36B1CEB2AD83DDC00CB74D5 /* Compile Kotlin Framework */ = { 163 | isa = PBXShellScriptBuildPhase; 164 | buildActionMask = 2147483647; 165 | files = ( 166 | ); 167 | inputFileListPaths = ( 168 | ); 169 | inputPaths = ( 170 | ); 171 | name = "Compile Kotlin Framework"; 172 | outputFileListPaths = ( 173 | ); 174 | outputPaths = ( 175 | ); 176 | runOnlyForDeploymentPostprocessing = 0; 177 | shellPath = /bin/sh; 178 | shellScript = "if [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \\\"YES\\\"\"\n exit 0\nfi\ncd \"$SRCROOT/..\"\n./gradlew :composeApp:embedAndSignAppleFrameworkForXcode\n"; 179 | }; 180 | /* End PBXShellScriptBuildPhase section */ 181 | 182 | /* Begin PBXSourcesBuildPhase section */ 183 | 7555FF77242A565900829871 /* Sources */ = { 184 | isa = PBXSourcesBuildPhase; 185 | buildActionMask = 2147483647; 186 | files = ( 187 | 2152FB042600AC8F00CF470E /* iOSApp.swift in Sources */, 188 | 7555FF83242A565900829871 /* ContentView.swift in Sources */, 189 | ); 190 | runOnlyForDeploymentPostprocessing = 0; 191 | }; 192 | /* End PBXSourcesBuildPhase section */ 193 | 194 | /* Begin XCBuildConfiguration section */ 195 | 7555FFA3242A565B00829871 /* Debug */ = { 196 | isa = XCBuildConfiguration; 197 | baseConfigurationReference = AB3632DC29227652001CCB65 /* Config.xcconfig */; 198 | buildSettings = { 199 | ALWAYS_SEARCH_USER_PATHS = NO; 200 | CLANG_ANALYZER_NONNULL = YES; 201 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 202 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 203 | CLANG_CXX_LIBRARY = "libc++"; 204 | CLANG_ENABLE_MODULES = YES; 205 | CLANG_ENABLE_OBJC_ARC = YES; 206 | CLANG_ENABLE_OBJC_WEAK = YES; 207 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 208 | CLANG_WARN_BOOL_CONVERSION = YES; 209 | CLANG_WARN_COMMA = YES; 210 | CLANG_WARN_CONSTANT_CONVERSION = YES; 211 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 212 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 213 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 214 | CLANG_WARN_EMPTY_BODY = YES; 215 | CLANG_WARN_ENUM_CONVERSION = YES; 216 | CLANG_WARN_INFINITE_RECURSION = YES; 217 | CLANG_WARN_INT_CONVERSION = YES; 218 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 219 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 220 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 221 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 222 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 223 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 224 | CLANG_WARN_STRICT_PROTOTYPES = YES; 225 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 226 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 227 | CLANG_WARN_UNREACHABLE_CODE = YES; 228 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 229 | COPY_PHASE_STRIP = NO; 230 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 231 | ENABLE_STRICT_OBJC_MSGSEND = YES; 232 | ENABLE_TESTABILITY = YES; 233 | ENABLE_USER_SCRIPT_SANDBOXING = NO; 234 | GCC_C_LANGUAGE_STANDARD = gnu11; 235 | GCC_DYNAMIC_NO_PIC = NO; 236 | GCC_NO_COMMON_BLOCKS = YES; 237 | GCC_OPTIMIZATION_LEVEL = 0; 238 | GCC_PREPROCESSOR_DEFINITIONS = ( 239 | "DEBUG=1", 240 | "$(inherited)", 241 | ); 242 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 243 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 244 | GCC_WARN_UNDECLARED_SELECTOR = YES; 245 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 246 | GCC_WARN_UNUSED_FUNCTION = YES; 247 | GCC_WARN_UNUSED_VARIABLE = YES; 248 | IPHONEOS_DEPLOYMENT_TARGET = 15.3; 249 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 250 | MTL_FAST_MATH = YES; 251 | ONLY_ACTIVE_ARCH = YES; 252 | SDKROOT = iphoneos; 253 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 254 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 255 | }; 256 | name = Debug; 257 | }; 258 | 7555FFA4242A565B00829871 /* Release */ = { 259 | isa = XCBuildConfiguration; 260 | baseConfigurationReference = AB3632DC29227652001CCB65 /* Config.xcconfig */; 261 | buildSettings = { 262 | ALWAYS_SEARCH_USER_PATHS = NO; 263 | CLANG_ANALYZER_NONNULL = YES; 264 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 265 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 266 | CLANG_CXX_LIBRARY = "libc++"; 267 | CLANG_ENABLE_MODULES = YES; 268 | CLANG_ENABLE_OBJC_ARC = YES; 269 | CLANG_ENABLE_OBJC_WEAK = YES; 270 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 271 | CLANG_WARN_BOOL_CONVERSION = YES; 272 | CLANG_WARN_COMMA = YES; 273 | CLANG_WARN_CONSTANT_CONVERSION = YES; 274 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 275 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 276 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 277 | CLANG_WARN_EMPTY_BODY = YES; 278 | CLANG_WARN_ENUM_CONVERSION = YES; 279 | CLANG_WARN_INFINITE_RECURSION = YES; 280 | CLANG_WARN_INT_CONVERSION = YES; 281 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 282 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 283 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 284 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 285 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 286 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 287 | CLANG_WARN_STRICT_PROTOTYPES = YES; 288 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 289 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 290 | CLANG_WARN_UNREACHABLE_CODE = YES; 291 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 292 | COPY_PHASE_STRIP = NO; 293 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 294 | ENABLE_NS_ASSERTIONS = NO; 295 | ENABLE_STRICT_OBJC_MSGSEND = YES; 296 | ENABLE_USER_SCRIPT_SANDBOXING = NO; 297 | GCC_C_LANGUAGE_STANDARD = gnu11; 298 | GCC_NO_COMMON_BLOCKS = YES; 299 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 300 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 301 | GCC_WARN_UNDECLARED_SELECTOR = YES; 302 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 303 | GCC_WARN_UNUSED_FUNCTION = YES; 304 | GCC_WARN_UNUSED_VARIABLE = YES; 305 | IPHONEOS_DEPLOYMENT_TARGET = 15.3; 306 | MTL_ENABLE_DEBUG_INFO = NO; 307 | MTL_FAST_MATH = YES; 308 | SDKROOT = iphoneos; 309 | SWIFT_COMPILATION_MODE = wholemodule; 310 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 311 | VALIDATE_PRODUCT = YES; 312 | }; 313 | name = Release; 314 | }; 315 | 7555FFA6242A565B00829871 /* Debug */ = { 316 | isa = XCBuildConfiguration; 317 | buildSettings = { 318 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 319 | CODE_SIGN_IDENTITY = "Apple Development"; 320 | CODE_SIGN_STYLE = Automatic; 321 | DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; 322 | DEVELOPMENT_TEAM = "${TEAM_ID}"; 323 | ENABLE_PREVIEWS = YES; 324 | FRAMEWORK_SEARCH_PATHS = ( 325 | "$(inherited)", 326 | "$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)\n$(SRCROOT)/../composeApp/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)", 327 | ); 328 | INFOPLIST_FILE = iosApp/Info.plist; 329 | IPHONEOS_DEPLOYMENT_TARGET = 15.3; 330 | LD_RUNPATH_SEARCH_PATHS = ( 331 | "$(inherited)", 332 | "@executable_path/Frameworks", 333 | ); 334 | PRODUCT_BUNDLE_IDENTIFIER = "${BUNDLE_ID}${TEAM_ID}"; 335 | PRODUCT_NAME = "${APP_NAME}"; 336 | PROVISIONING_PROFILE_SPECIFIER = ""; 337 | SWIFT_VERSION = 5.0; 338 | TARGETED_DEVICE_FAMILY = "1,2"; 339 | }; 340 | name = Debug; 341 | }; 342 | 7555FFA7242A565B00829871 /* Release */ = { 343 | isa = XCBuildConfiguration; 344 | buildSettings = { 345 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 346 | CODE_SIGN_IDENTITY = "Apple Development"; 347 | CODE_SIGN_STYLE = Automatic; 348 | DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; 349 | DEVELOPMENT_TEAM = "${TEAM_ID}"; 350 | ENABLE_PREVIEWS = YES; 351 | FRAMEWORK_SEARCH_PATHS = ( 352 | "$(inherited)", 353 | "$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)\n$(SRCROOT)/../composeApp/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)", 354 | ); 355 | INFOPLIST_FILE = iosApp/Info.plist; 356 | IPHONEOS_DEPLOYMENT_TARGET = 15.3; 357 | LD_RUNPATH_SEARCH_PATHS = ( 358 | "$(inherited)", 359 | "@executable_path/Frameworks", 360 | ); 361 | PRODUCT_BUNDLE_IDENTIFIER = "${BUNDLE_ID}${TEAM_ID}"; 362 | PRODUCT_NAME = "${APP_NAME}"; 363 | PROVISIONING_PROFILE_SPECIFIER = ""; 364 | SWIFT_VERSION = 5.0; 365 | TARGETED_DEVICE_FAMILY = "1,2"; 366 | }; 367 | name = Release; 368 | }; 369 | /* End XCBuildConfiguration section */ 370 | 371 | /* Begin XCConfigurationList section */ 372 | 7555FF76242A565900829871 /* Build configuration list for PBXProject "iosApp" */ = { 373 | isa = XCConfigurationList; 374 | buildConfigurations = ( 375 | 7555FFA3242A565B00829871 /* Debug */, 376 | 7555FFA4242A565B00829871 /* Release */, 377 | ); 378 | defaultConfigurationIsVisible = 0; 379 | defaultConfigurationName = Release; 380 | }; 381 | 7555FFA5242A565B00829871 /* Build configuration list for PBXNativeTarget "iosApp" */ = { 382 | isa = XCConfigurationList; 383 | buildConfigurations = ( 384 | 7555FFA6242A565B00829871 /* Debug */, 385 | 7555FFA7242A565B00829871 /* Release */, 386 | ); 387 | defaultConfigurationIsVisible = 0; 388 | defaultConfigurationName = Release; 389 | }; 390 | /* End XCConfigurationList section */ 391 | }; 392 | rootObject = 7555FF73242A565900829871 /* Project object */; 393 | } -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/dev/ech0/torbox/multiplatform/api/TorboxAPI.kt: -------------------------------------------------------------------------------- 1 | package dev.ech0.torbox.multiplatform.api 2 | 3 | 4 | import androidx.navigation.NavHostController 5 | import com.russhwolf.settings.Settings 6 | import io.ktor.client.* 7 | import io.ktor.client.plugins.* 8 | import io.ktor.client.request.* 9 | import io.ktor.client.request.forms.* 10 | import io.ktor.client.statement.* 11 | import io.ktor.http.* 12 | import kotlinx.io.IOException 13 | import kotlinx.serialization.json.* 14 | 15 | const val base = "https://api.torbox.app/v1/api/" 16 | const val base_search = "https://search-api.torbox.app/" 17 | lateinit var torboxAPI: TorboxAPI 18 | class TorboxAPI(private var key: String, navController: NavHostController?) { 19 | init { 20 | if (key == "__") { 21 | navController?.navigate("Error/${"go set your api key, you goofball".encodeURLPath()}") 22 | } 23 | } 24 | 25 | private val ktor = HttpClient { 26 | install(HttpTimeout) { 27 | requestTimeoutMillis = 120000 28 | connectTimeoutMillis = 120000 29 | socketTimeoutMillis = 120000 30 | } 31 | 32 | } 33 | 34 | fun getApiKey(): String { 35 | return key 36 | } 37 | 38 | fun setApiKey(newKey: String) { 39 | key = newKey 40 | } 41 | 42 | suspend fun getListOfTorrents(): JsonObject { 43 | val response = ktor.get(base + "torrents/mylist?bypass_cache=true") { 44 | headers { 45 | append(HttpHeaders.Authorization, "Bearer $key") 46 | } 47 | } 48 | val json = Json.decodeFromString(response.bodyAsText()) 49 | if (!json["success"]!!.jsonPrimitive.boolean) { 50 | throw IOException("Failed to get list of torrents with ${json}") 51 | } 52 | return json 53 | } 54 | 55 | suspend fun getListOfUsenet(): JsonObject { 56 | val response = ktor.get(base + "usenet/mylist?bypass_cache=true") { 57 | headers { 58 | append(HttpHeaders.Authorization, "Bearer $key") 59 | } 60 | } 61 | val json = Json.decodeFromString(response.bodyAsText()) 62 | if (!json["success"]!!.jsonPrimitive.boolean) { 63 | throw IOException("Failed to get list of usenet downloads with ${json}") 64 | } 65 | return json 66 | } 67 | 68 | suspend fun getTorrentLink(id: Number, shouldGetZip: Boolean): JsonObject { 69 | val response = 70 | ktor.get(base + "torrents/requestdl?token=${key}&torrent_id=${id}&file_id=0&zip_link=$shouldGetZip") 71 | val json = Json.decodeFromString(response.bodyAsText()) 72 | if (!json["success"]!!.jsonPrimitive.boolean) { 73 | throw IOException("Failed to get torrent download link with ${json}") 74 | } 75 | return json 76 | } 77 | 78 | suspend fun getTorrentLink(id: Number, fileId: Number, shouldGetZip: Boolean): JsonObject { 79 | val response = 80 | ktor.get(base + "torrents/requestdl?token=${key}&torrent_id=${id}&file_id=$fileId&zip_link=$shouldGetZip") 81 | val json = Json.decodeFromString(response.bodyAsText()) 82 | if (!json["success"]!!.jsonPrimitive.boolean) { 83 | throw IOException("Failed to get torrent download link with ${json}") 84 | } 85 | return json 86 | } 87 | 88 | suspend fun getUsenetLink(id: Number, shouldGetZip: Boolean): JsonObject { 89 | val response = 90 | ktor.get(base + "usenet/requestdl?token=${key}&usenet_id=${id}&file_id=0&zip_link=$shouldGetZip") 91 | val json = Json.decodeFromString(response.bodyAsText()) 92 | if (!json["success"]!!.jsonPrimitive.boolean) { 93 | throw IOException("Failed to get usenet download link with ${json}") 94 | } 95 | return json 96 | } 97 | 98 | suspend fun controlTorrent(id: Number, operation: String): JsonObject { 99 | val response = ktor.post(base + "torrents/controltorrent") { 100 | headers { 101 | append(HttpHeaders.Authorization, "Bearer $key") 102 | } 103 | contentType(ContentType.Application.Json) 104 | var jsonObject = JsonObject( 105 | mapOf( 106 | Pair("torrent_id", JsonPrimitive(id)), 107 | Pair("operation", JsonPrimitive(operation)), 108 | Pair("all", JsonPrimitive(false)) 109 | ) 110 | ) 111 | setBody(jsonObject.toString()) 112 | } 113 | val json = Json.decodeFromString(response.bodyAsText()) 114 | if (!json["success"]!!.jsonPrimitive.boolean) { 115 | throw IOException("Failed to get modify torrent download with ${json}") 116 | } 117 | return json 118 | } 119 | 120 | suspend fun controlUsenet(id: Number, operation: String): JsonObject { 121 | val response = ktor.post(base + "usenet/controlusenetdownload") { 122 | headers { 123 | append(HttpHeaders.Authorization, "Bearer $key") 124 | } 125 | contentType(ContentType.Application.Json) 126 | var jsonObject = JsonObject( 127 | mapOf( 128 | Pair("usenet_id", JsonPrimitive(id)), 129 | Pair("operation", JsonPrimitive(operation.encodeURLPath())), 130 | Pair("all", JsonPrimitive(false)) 131 | ) 132 | ) 133 | setBody(jsonObject.toString()) 134 | } 135 | val json = Json.decodeFromString(response.bodyAsText()) 136 | if (!json["success"]!!.jsonPrimitive.boolean) { 137 | throw IOException("Failed to get modify torrent download with ${json}") 138 | } 139 | return json 140 | } 141 | 142 | suspend fun createTorrent(magnet: String): JsonObject { 143 | val response = ktor.post(base + "torrents/createtorrent") { 144 | headers { 145 | append(HttpHeaders.Authorization, "Bearer $key") 146 | } 147 | setBody( 148 | MultiPartFormDataContent( 149 | formData { 150 | append("magnet", magnet) 151 | }, boundary = "WebAppBoundary" 152 | ) 153 | ) 154 | } 155 | val json = Json.decodeFromString(response.bodyAsText()) 156 | 157 | if (!json["success"]!!.jsonPrimitive.boolean) { 158 | throw IOException("Failed to get create torrent download with ${json}") 159 | } 160 | return json 161 | } 162 | 163 | suspend fun createUsenet(link: String): JsonObject { 164 | val response = ktor.post(base + "usenet/createusenetdownload") { 165 | headers { 166 | append(HttpHeaders.Authorization, "Bearer $key") 167 | } 168 | setBody( 169 | MultiPartFormDataContent( 170 | formData { 171 | append("link", link) 172 | }, boundary = "WebAppBoundary" 173 | ) 174 | ) 175 | } 176 | val json = Json.decodeFromString(response.bodyAsText()) 177 | if (!json["success"]!!.jsonPrimitive.boolean) { 178 | throw IOException("Failed to get create usenet download with ${json}") 179 | } 180 | return json 181 | } 182 | 183 | suspend fun createUsenet(torrent: ByteArray): JsonObject { 184 | val response = ktor.post(base + "usenet/createusenetdownload") { 185 | headers { 186 | append(HttpHeaders.Authorization, "Bearer $key") 187 | } 188 | setBody( 189 | MultiPartFormDataContent( 190 | formData { 191 | append("file", torrent, Headers.build { 192 | append(HttpHeaders.ContentType, "application/octet-stream") 193 | append(HttpHeaders.ContentDisposition, "form-data; name=\"file\"; filename=\"file.nzb\"") 194 | }) 195 | }, boundary = "WebAppBoundary" 196 | ) 197 | ) 198 | } 199 | val json = Json.decodeFromString(response.bodyAsText()) 200 | if (!json["success"]!!.jsonPrimitive.boolean) { 201 | throw IOException("Failed to get create usenet download with ${json}") 202 | } 203 | return json 204 | } 205 | 206 | suspend fun createTorrent(torrent: ByteArray): JsonObject { 207 | val response = ktor.post(base + "torrents/createtorrent") { 208 | headers { 209 | append(HttpHeaders.Authorization, "Bearer $key") 210 | } 211 | setBody( 212 | MultiPartFormDataContent( 213 | formData { 214 | append("torrent", torrent, Headers.build { 215 | append(HttpHeaders.ContentType, "application/x-bittorrent") 216 | append( 217 | HttpHeaders.ContentDisposition, 218 | "form-data; name=\"file\"; filename=\"file.torrent\"" 219 | ) 220 | }) 221 | }, boundary = "WebAppBoundary" 222 | ) 223 | ) 224 | } 225 | val json = Json.decodeFromString(response.bodyAsText()) 226 | if (!json["success"]!!.jsonPrimitive.boolean) { 227 | throw IOException("Failed to get create torrent download with ${json}") 228 | } 229 | return json 230 | } 231 | 232 | suspend fun searchTorrents(query: String, season: Int, episode: Int): JsonObject { 233 | val response = 234 | ktor.get(base_search + "torrents/${query.encodeURLPath()}?check_cache=true&check_owned=true&season=$season&episode=$episode&metadata=false&search_user_engines=${Settings().getBoolean("userEngines", true)}") { 235 | headers { 236 | append(HttpHeaders.Authorization, "Bearer $key") 237 | } 238 | } 239 | println(response.bodyAsText()) 240 | val json = Json.decodeFromString(response.bodyAsText()) 241 | if (!json["success"]!!.jsonPrimitive.boolean) { 242 | return JsonObject(mapOf(Pair("data", JsonObject(mapOf(Pair("torrents", JsonArray(listOf()))))))) 243 | } 244 | println(response.bodyAsText()) 245 | return json 246 | } 247 | 248 | suspend fun searchTorrentsId(query: String): JsonObject { 249 | val response = 250 | ktor.get(base_search + "torrents/${query.encodeURLPath()}?check_cache=true&check_owned=true&metadata=false&search_user_engines=${Settings().getBoolean("userEngines", true)}") { 251 | headers { 252 | append(HttpHeaders.Authorization, "Bearer $key") 253 | } 254 | } 255 | val json = Json.decodeFromString(response.bodyAsText()) 256 | if (!json["success"]!!.jsonPrimitive.boolean) { 257 | return JsonObject(mapOf(Pair("data", JsonObject(mapOf(Pair("torrents", JsonArray(listOf()))))))) 258 | } 259 | return json 260 | } 261 | 262 | suspend fun searchUsenet(query: String, season: Int, episode: Int): JsonObject { 263 | 264 | val response = 265 | ktor.get(base_search + "usenet/${query.encodeURLPath()}?check_cache=true&check_owned=true&season=$season&episode=$episode&metadata=false&search_user_engines=${Settings().getBoolean("userEngines", true)}") { 266 | headers { 267 | append(HttpHeaders.Authorization, "Bearer $key") 268 | } 269 | } 270 | val json = Json.decodeFromString(response.bodyAsText()) 271 | if (!json["success"]!!.jsonPrimitive.boolean) { 272 | throw IOException("Failed to search torrents with ${json}") 273 | } 274 | return json 275 | } 276 | 277 | suspend fun searchUsenetId(query: String): JsonObject { 278 | val response = 279 | ktor.get(base_search + "usenet/${query.encodeURLPath()}?check_cache=true&check_owned=true&metadata=false&search_user_engines=${Settings().getBoolean("userEngines", true)}") { 280 | headers { 281 | append(HttpHeaders.Authorization, "Bearer $key") 282 | } 283 | } 284 | val json = Json.decodeFromString(response.bodyAsText()) 285 | if (!json["success"]!!.jsonPrimitive.boolean) { 286 | throw IOException("Failed to search torrents with ${json}") 287 | } 288 | return json 289 | } 290 | 291 | suspend fun searchTorrents(query: String): JsonObject { 292 | println(query.encodeURLPath()) 293 | val response = 294 | ktor.get(base_search + "torrents/search/${query.encodeURLPath()}?check_cache=true&check_owned=true&search_user_engines=${Settings().getBoolean("userEngines", true)}") { 295 | headers { 296 | append(HttpHeaders.Authorization, "Bearer $key") 297 | } 298 | } 299 | val json = Json.decodeFromString(response.bodyAsText()) 300 | if (!json["success"]!!.jsonPrimitive.boolean) { 301 | throw IOException("Failed to search torrents with ${json}") 302 | } 303 | return json 304 | } 305 | 306 | suspend fun searchUsenet(query: String): JsonObject { 307 | val response = 308 | ktor.get(base_search + "usenet/search/${query.encodeURLPath()}?check_cache=true&check_owned=true&search_user_engines=${Settings().getBoolean("userEngines", true)}") { 309 | headers { 310 | append(HttpHeaders.Authorization, "Bearer $key") 311 | } 312 | } 313 | val json = Json.decodeFromString(response.bodyAsText()) 314 | if (!json["success"]!!.jsonPrimitive.boolean) { 315 | throw IOException("Failed to search torrents with ${json}") 316 | } 317 | return json 318 | } 319 | 320 | suspend fun getTorrentInfo(query: String): JsonObject { 321 | val response = ktor.get(base + "torrents/torrentinfo?hash=${query.encodeURLPath()}") { 322 | headers { 323 | append(HttpHeaders.Authorization, "Bearer $key") 324 | } 325 | } 326 | val json = Json.decodeFromString(response.bodyAsText()) 327 | if (!json["success"]!!.jsonPrimitive.boolean) { 328 | throw IOException("Failed to search torrents with ${json}") 329 | } 330 | return json 331 | } 332 | 333 | suspend fun checkApiKey(toCheck: String): Boolean { 334 | val response = ktor.get(base + "user/me?settings=false") { 335 | headers { 336 | append(HttpHeaders.Authorization, "Bearer $key") 337 | } 338 | } 339 | val json = Json.decodeFromString(response.bodyAsText()) 340 | if (!json["success"]!!.jsonPrimitive.boolean) { 341 | return false 342 | } 343 | Settings().putInt("plan", json["data"]!!.jsonObject["plan"]!!.jsonPrimitive.int) 344 | Settings().putString("userdata", json["data"].toString()) 345 | return true 346 | } 347 | 348 | suspend fun getMetaFromId(id: String): JsonObject { 349 | val response = ktor.get("${base_search}meta/${id.encodeURLPath()}") {} 350 | val json = Json.decodeFromString(response.bodyAsText()) 351 | return json 352 | } 353 | 354 | suspend fun getTorrentsIMDB(query: String, season: Int, episode: Int): JsonObject { 355 | val response = 356 | ktor.get(base_search + "torrents/$query?metadata=false&season=$season&episode=$episode&check_cache=true&check_owned=true") { 357 | headers { 358 | append(HttpHeaders.Authorization, "Bearer $key") 359 | } 360 | } 361 | val json: JsonObject = Json.decodeFromString(response.bodyAsText()) 362 | if (!json["success"]!!.jsonPrimitive.boolean) { 363 | throw IOException("Failed to search torrents with ${json}") 364 | } 365 | return json 366 | } 367 | 368 | suspend fun checkCache(hash: String): JsonObject { 369 | val response = 370 | ktor.get(base + "torrents/checkcached?hash=${hash.encodeURLPath()}&format=object&list_files=true") { 371 | headers { 372 | append(HttpHeaders.Authorization, "Bearer $key") 373 | } 374 | } 375 | 376 | val json: JsonObject = Json.decodeFromString(response.bodyAsText()) 377 | if (!json["success"]!!.jsonPrimitive.boolean) { 378 | throw IOException("Failed to search torrents with ${json}") 379 | } 380 | return json 381 | } 382 | } -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/dev/ech0/torbox/multiplatform/ui/pages/watch/WatchPage.kt: -------------------------------------------------------------------------------- 1 | package dev.ech0.torbox.multiplatform.ui.pages.watch 2 | 3 | import androidx.compose.animation.Crossfade 4 | import androidx.compose.foundation.* 5 | import androidx.compose.foundation.layout.* 6 | import androidx.compose.foundation.pager.HorizontalPager 7 | import androidx.compose.foundation.pager.PagerState 8 | import androidx.compose.foundation.shape.RoundedCornerShape 9 | import androidx.compose.foundation.text.selection.SelectionContainer 10 | import androidx.compose.material.icons.Icons 11 | import androidx.compose.material.icons.filled.CheckCircle 12 | import androidx.compose.material.icons.filled.KeyboardDoubleArrowLeft 13 | import androidx.compose.material.icons.filled.KeyboardDoubleArrowRight 14 | import androidx.compose.material.icons.filled.Timer 15 | import androidx.compose.material3.* 16 | import androidx.compose.runtime.* 17 | import androidx.compose.ui.Alignment 18 | import androidx.compose.ui.Modifier 19 | import androidx.compose.ui.draw.blur 20 | import androidx.compose.ui.draw.clip 21 | import androidx.compose.ui.draw.drawWithContent 22 | import androidx.compose.ui.geometry.Offset 23 | import androidx.compose.ui.graphics.* 24 | import androidx.compose.ui.hapticfeedback.HapticFeedbackType 25 | import androidx.compose.ui.layout.ContentScale 26 | import androidx.compose.ui.platform.LocalHapticFeedback 27 | import androidx.compose.ui.text.font.FontStyle 28 | import androidx.compose.ui.text.font.FontWeight 29 | import androidx.compose.ui.text.style.TextAlign 30 | import androidx.compose.ui.text.style.TextOverflow 31 | import androidx.compose.ui.unit.dp 32 | import androidx.compose.ui.window.Dialog 33 | import androidx.navigation.NavController 34 | import coil3.compose.AsyncImage 35 | import dev.ech0.torbox.multiplatform.api.tmdbApi 36 | import dev.ech0.torbox.multiplatform.api.traktApi 37 | import dev.ech0.torbox.multiplatform.ui.components.LoadingScreen 38 | import dev.ech0.torbox.multiplatform.ui.components.TorrentSelectDialogArguments 39 | import dev.ech0.torbox.multiplatform.ui.components.TorrentSelectDialog 40 | import kotlinx.coroutines.launch 41 | import kotlinx.serialization.json.* 42 | 43 | @OptIn(ExperimentalFoundationApi::class) 44 | @Composable 45 | fun WatchPage(meta: JsonObject, navController: NavController) { 46 | var overviewDialog by remember { mutableStateOf(false) } 47 | var overviewDialogContent by remember { mutableStateOf("") } 48 | val type = WatchSearchResultType.valueOf(meta["media_type"]!!.jsonPrimitive.content.uppercase()) 49 | var details by remember { mutableStateOf(JsonObject(emptyMap())) } 50 | var seasons by remember { mutableStateOf(arrayOfNulls(0)) } 51 | var seasonCarouselState by remember { mutableStateOf(PagerState(pageCount = { 1 })) } 52 | var traktResponse by remember { mutableStateOf(JsonObject(emptyMap())) } 53 | var shouldLoad by remember { mutableStateOf(false) } 54 | val haptics = LocalHapticFeedback.current 55 | val scope = rememberCoroutineScope() 56 | var traktId by remember { mutableStateOf(0) } 57 | var torrentSelectDialogArgs by remember { 58 | mutableStateOf( 59 | TorrentSelectDialogArguments( 60 | 0, 0, "", WatchSearchResultType.MOVIE, {}, navController 61 | ) 62 | ) 63 | } 64 | var displayTorrentSelectDialog by remember { mutableStateOf(false) } 65 | LaunchedEffect(true) { 66 | traktId = traktApi.getTraktIdFromTMDB(meta["id"]!!.jsonPrimitive.long) 67 | if (type == WatchSearchResultType.TV) { 68 | traktResponse = traktApi.getWatchedShow(traktId.toLong()) ?: JsonObject(emptyMap()) 69 | details = tmdbApi.getTvDetails(meta["id"]!!.jsonPrimitive.int) 70 | seasons = arrayOfNulls(details["number_of_seasons"]!!.jsonPrimitive.int) 71 | seasons[0] = tmdbApi.getSeasonDetails(meta["id"]!!.jsonPrimitive.int, 1) 72 | seasonCarouselState = PagerState(pageCount = { seasons.size }) 73 | } else if (type == WatchSearchResultType.MOVIE) { 74 | traktResponse = traktApi.getWatchedMovie(traktId.toLong()) ?: JsonObject(emptyMap()) 75 | details = tmdbApi.getMovieDetails(meta["id"]!!.jsonPrimitive.int) 76 | seasons = arrayOfNulls(1) 77 | seasons[0] = Json.parseToJsonElement( 78 | """ 79 | { 80 | "air_date": "${details["release_date"]!!.jsonPrimitive.content}", 81 | "episodes": [ 82 | { 83 | "episode_number": 1, 84 | "name": "Movie", 85 | "runtime": ${details["runtime"]!!.jsonPrimitive.int}, 86 | "watched": ${traktApi.getWatchedMovie(traktId.toLong()) != null} 87 | } 88 | ] 89 | 90 | } 91 | """.trimIndent() 92 | ).jsonObject 93 | // recompose 94 | seasonCarouselState = PagerState(pageCount = { 1 }) 95 | } 96 | } 97 | LaunchedEffect(seasonCarouselState) { 98 | snapshotFlow { seasonCarouselState.currentPage }.collect { page -> 99 | haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove) 100 | if (seasons.size != 0) { 101 | if (seasons[page] == null) { 102 | val updatedSeasons = seasons.copyOf() 103 | updatedSeasons[page] = tmdbApi.getSeasonDetails(meta["id"]!!.jsonPrimitive.int, page + 1) 104 | seasons = updatedSeasons 105 | } 106 | } 107 | } 108 | } 109 | Column(modifier = Modifier.verticalScroll(rememberScrollState())) { 110 | Box( 111 | modifier = Modifier.fillMaxWidth().align(Alignment.CenterHorizontally) 112 | ) { 113 | AsyncImage( 114 | model = tmdbApi.imageHelper( 115 | meta["backdrop_path"]?.jsonPrimitive?.contentOrNull ?: meta["poster_path"]?.jsonPrimitive?.contentOrNull 116 | ?: "" 117 | ), 118 | contentDescription = null, 119 | modifier = Modifier.clip(RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp)).blur(25.dp) 120 | .height(200.dp).graphicsLayer { alpha = 0.99F }.drawWithContent { 121 | val colors = listOf( 122 | Color.Black, Color.Transparent 123 | ) 124 | drawContent() 125 | drawRect( 126 | brush = Brush.verticalGradient(colors, startY = 0f, endY = size.height), 127 | blendMode = BlendMode.DstIn 128 | ) 129 | }, 130 | contentScale = ContentScale.Crop 131 | ) 132 | SelectionContainer { 133 | Text( 134 | meta["name"]?.jsonPrimitive?.contentOrNull ?: meta["title"]?.jsonPrimitive?.contentOrNull ?: "", 135 | style = MaterialTheme.typography.headlineMedium.copy( 136 | shadow = Shadow( 137 | color = Color.Gray, offset = Offset(0f, 0f), blurRadius = 25f 138 | ) 139 | ), 140 | fontWeight = FontWeight.ExtraBold, 141 | modifier = Modifier.fillMaxWidth().padding(top = 178.dp).padding(horizontal = 24.dp), 142 | textAlign = TextAlign.Center 143 | ) 144 | } 145 | 146 | } 147 | if (details.contains("tagline")) { 148 | if (details["tagline"]!!.jsonPrimitive.content.isNotBlank()) { 149 | Text( 150 | details["tagline"]!!.jsonPrimitive.content, 151 | style = MaterialTheme.typography.bodyMedium, 152 | fontWeight = FontWeight.Light, 153 | fontStyle = FontStyle.Italic, 154 | color = MaterialTheme.colorScheme.onSurfaceVariant, 155 | modifier = Modifier.padding(top = 12.dp).align(Alignment.CenterHorizontally) 156 | ) 157 | } 158 | } 159 | if (details.contains("content_ratings")) { 160 | val ratings = details["content_ratings"]!!.jsonObject["results"]!!.jsonArray 161 | if (ratings.size > 0) { 162 | var rating = ratings[0].jsonObject 163 | for (i in 0 until ratings.size) { 164 | if (ratings[i].jsonObject["iso_3166_1"]!!.jsonPrimitive.content == "US") { 165 | rating = ratings[i].jsonObject 166 | break 167 | } 168 | } 169 | Text( 170 | rating["rating"]!!.jsonPrimitive.content, 171 | style = MaterialTheme.typography.bodyMedium, 172 | fontWeight = FontWeight.Bold, 173 | color = MaterialTheme.colorScheme.onSurfaceVariant, 174 | modifier = Modifier.padding( 175 | top = if (details.contains("tagline")) { 176 | 4.dp 177 | } else { 178 | 12.dp 179 | } 180 | ).align(Alignment.CenterHorizontally) 181 | ) 182 | } 183 | } 184 | 185 | if (meta.contains("overview")) { 186 | Text( 187 | meta["overview"]!!.jsonPrimitive.content, 188 | style = MaterialTheme.typography.bodyMedium, 189 | modifier = Modifier.padding(vertical = 12.dp, horizontal = 24.dp).clickable { 190 | overviewDialogContent = meta["overview"]!!.jsonPrimitive.content; overviewDialog = true 191 | }, 192 | color = MaterialTheme.colorScheme.onSurfaceVariant, 193 | overflow = TextOverflow.Ellipsis, 194 | maxLines = 2 195 | ) 196 | } 197 | Crossfade(targetState = seasonCarouselState.currentPage) { state -> 198 | Column(modifier = Modifier.fillMaxSize()) { 199 | if (seasons.size > state && seasons[state] != null) { 200 | if (type == WatchSearchResultType.TV) { 201 | Row( 202 | verticalAlignment = Alignment.CenterVertically, 203 | horizontalArrangement = Arrangement.Center, 204 | modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp) 205 | ) { 206 | IconButton( 207 | modifier = Modifier.size(24.dp).padding(start = 8.dp), onClick = { 208 | scope.launch { 209 | seasonCarouselState.animateScrollToPage(seasonCarouselState.currentPage - 1) 210 | } 211 | }) { 212 | Icon( 213 | Icons.Filled.KeyboardDoubleArrowLeft, 214 | contentDescription = null, 215 | tint = MaterialTheme.colorScheme.onSurfaceVariant 216 | ) 217 | } 218 | HorizontalPager(state = seasonCarouselState, modifier = Modifier.weight(1f)) { i -> 219 | Text( 220 | text = "Season ${i + 1}", 221 | textAlign = TextAlign.Center, 222 | style = MaterialTheme.typography.titleMedium, 223 | modifier = Modifier.fillMaxWidth() 224 | ) 225 | } 226 | 227 | IconButton( 228 | modifier = Modifier.size(24.dp).padding(end = 8.dp), onClick = { 229 | scope.launch { 230 | seasonCarouselState.animateScrollToPage(seasonCarouselState.currentPage + 1) 231 | } 232 | }) { 233 | Icon( 234 | Icons.Filled.KeyboardDoubleArrowRight, 235 | contentDescription = null, 236 | tint = MaterialTheme.colorScheme.onSurfaceVariant 237 | ) 238 | } 239 | } 240 | } 241 | for (i in 0 until seasons[state]!!["episodes"]!!.jsonArray.size) { 242 | val episode = seasons[state]!!["episodes"]!!.jsonArray[i].jsonObject 243 | var watched = false 244 | if (type == WatchSearchResultType.TV) { 245 | if (traktResponse.contains("data")) { 246 | for (k in 0 until traktResponse["data"]!!.jsonArray.size) { 247 | val episodeObject = 248 | traktResponse["data"]!!.jsonArray[k].jsonObject["episode"]!!.jsonObject 249 | if (episodeObject["season"]!!.jsonPrimitive.int == state + 1 && episodeObject["number"]!!.jsonPrimitive.int == i + 1) { 250 | watched = true 251 | } 252 | } 253 | } 254 | } else { 255 | if (traktResponse.contains("type")) { 256 | watched = true 257 | } 258 | } 259 | Box( 260 | modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp) 261 | .clip(RoundedCornerShape(12.dp)).wrapContentHeight() 262 | .background(if (episode.contains("still_path")) Color.Transparent else MaterialTheme.colorScheme.surfaceVariant) 263 | .combinedClickable(onClick = { 264 | torrentSelectDialogArgs = TorrentSelectDialogArguments( 265 | state + 1, i + 1, "imdb:${ 266 | details["external_ids"]!!.jsonObject["imdb_id"]!!.jsonPrimitive.content 267 | }", type, { 268 | displayTorrentSelectDialog = false 269 | }, navController = navController 270 | ) 271 | displayTorrentSelectDialog = true 272 | scope.launch { 273 | if (type == WatchSearchResultType.MOVIE) { 274 | traktApi.addMovie(meta["id"]!!.jsonPrimitive.long) 275 | traktResponse = traktApi.getWatchedMovie(traktId.toLong()) ?: JsonObject( 276 | emptyMap() 277 | ) 278 | } else { 279 | traktApi.addShow(meta["id"]!!.jsonPrimitive.long, state + 1, i + 1) 280 | traktResponse = traktApi.getWatchedShow(traktId.toLong()) ?: JsonObject( 281 | emptyMap() 282 | ) 283 | } 284 | } 285 | }, onLongClick = { 286 | haptics.performHapticFeedback(HapticFeedbackType.LongPress) 287 | scope.launch { 288 | if (watched) { 289 | if (type == WatchSearchResultType.MOVIE) { 290 | traktApi.removeMovie(meta["id"]!!.jsonPrimitive.long) 291 | traktResponse = 292 | traktApi.getWatchedMovie(traktId.toLong()) ?: JsonObject(emptyMap()) 293 | } else { 294 | traktApi.removeShow(meta["id"]!!.jsonPrimitive.long, state + 1, i + 1) 295 | traktResponse = 296 | traktApi.getWatchedShow(traktId.toLong()) ?: JsonObject(emptyMap()) 297 | } 298 | } else { 299 | if (type == WatchSearchResultType.MOVIE) { 300 | traktApi.addMovie(meta["id"]!!.jsonPrimitive.long) 301 | traktResponse = 302 | traktApi.getWatchedMovie(traktId.toLong()) ?: JsonObject(emptyMap()) 303 | } else { 304 | traktApi.addShow (meta["id"]!!.jsonPrimitive.long, state + 1, i + 1) 305 | traktResponse = 306 | traktApi.getWatchedShow(traktId.toLong()) ?: JsonObject(emptyMap()) 307 | } 308 | } 309 | } 310 | }) 311 | ) { 312 | if (episode.contains("still_path")) { 313 | AsyncImage( 314 | model = tmdbApi.imageHelper( 315 | episode["still_path"]!!.jsonPrimitive.content 316 | ), 317 | contentDescription = null, 318 | contentScale = ContentScale.Crop, 319 | modifier = Modifier.blur(25.dp).graphicsLayer { alpha = 0.5f }.matchParentSize() 320 | .drawWithContent { 321 | drawContent() 322 | if (watched) { 323 | drawRect(Color.Black.copy(alpha = 0.9f)) 324 | } 325 | }) 326 | 327 | } 328 | Row( 329 | modifier = Modifier.align(Alignment.Center).padding(8.dp) 330 | ) { 331 | Column( 332 | modifier = Modifier.align(Alignment.CenterVertically).padding(8.dp).weight(1f) 333 | ) { 334 | Text( 335 | "${i + 1}. ${episode["name"]!!.jsonPrimitive.content}", 336 | style = MaterialTheme.typography.titleLarge, 337 | modifier = Modifier.padding(2.dp), 338 | color = MaterialTheme.colorScheme.onSurface, 339 | fontWeight = FontWeight.Bold, 340 | ) 341 | Spacer(modifier = Modifier.weight(1f)) 342 | 343 | if (episode.contains("overview")) { 344 | Text( 345 | episode["overview"]!!.jsonPrimitive.content, 346 | style = MaterialTheme.typography.bodyMedium, 347 | modifier = Modifier.padding(2.dp), 348 | color = MaterialTheme.colorScheme.onSurfaceVariant, 349 | maxLines = 2, 350 | overflow = TextOverflow.Ellipsis 351 | ) 352 | } 353 | } 354 | Column( 355 | modifier = Modifier.align(Alignment.CenterVertically).padding(end = 4.dp) 356 | ) { 357 | Row( 358 | modifier = Modifier.align(Alignment.CenterHorizontally), 359 | verticalAlignment = Alignment.CenterVertically 360 | ) { 361 | Icon( 362 | Icons.Filled.Timer, 363 | contentDescription = null, 364 | tint = MaterialTheme.colorScheme.onSurfaceVariant, 365 | modifier = Modifier.size(12.dp).padding(end = 2.dp) 366 | ) 367 | Text( 368 | "${episode["runtime"]!!.jsonPrimitive.intOrNull ?: "Unknown "}m", 369 | style = MaterialTheme.typography.bodyMedium, 370 | color = MaterialTheme.colorScheme.onSurfaceVariant 371 | ) 372 | } 373 | if (watched) { 374 | Row(modifier = Modifier.align(Alignment.CenterHorizontally)) { 375 | Icon( 376 | Icons.Filled.CheckCircle, 377 | contentDescription = null, 378 | tint = MaterialTheme.colorScheme.primary, 379 | modifier = Modifier.size(16.dp) 380 | ) 381 | } 382 | } 383 | } 384 | } 385 | } 386 | 387 | } 388 | } else { 389 | Column(Modifier.fillMaxSize().padding(top = 16.dp)) { 390 | LoadingScreen() 391 | } 392 | } 393 | } 394 | } 395 | } 396 | if (overviewDialog) { 397 | Dialog( 398 | onDismissRequest = { overviewDialog = false }) { 399 | Box( 400 | modifier = Modifier.fillMaxSize().padding(16.dp).clip(RoundedCornerShape(12.dp)) 401 | .clickable(interactionSource = null, indication = null) { overviewDialog = false }) { 402 | Card( 403 | modifier = Modifier.align(Alignment.Center) 404 | .clickable(interactionSource = null, indication = null) { }) { 405 | SelectionContainer { 406 | Text( 407 | overviewDialogContent, 408 | style = MaterialTheme.typography.bodyMedium, 409 | modifier = Modifier.fillMaxWidth().padding(16.dp), 410 | color = MaterialTheme.colorScheme.onSurfaceVariant 411 | ) 412 | } 413 | } 414 | 415 | } 416 | } 417 | } 418 | if (displayTorrentSelectDialog) { 419 | TorrentSelectDialog(torrentSelectDialogArgs) 420 | } 421 | 422 | } --------------------------------------------------------------------------------