├── 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 | }
--------------------------------------------------------------------------------