├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .metadata ├── .vscode └── launch.json ├── LICENSE ├── README.md ├── analysis_options.yaml ├── android ├── .gitignore ├── app │ ├── build.gradle │ └── src │ │ ├── debug │ │ └── AndroidManifest.xml │ │ ├── main │ │ ├── AndroidManifest.xml │ │ ├── kotlin │ │ │ └── com │ │ │ │ └── example │ │ │ │ └── odin │ │ │ │ └── MainActivity.kt │ │ └── res │ │ │ ├── drawable-v21 │ │ │ ├── app_icon_odintv.png │ │ │ └── launch_background.xml │ │ │ ├── drawable │ │ │ ├── app_icon_odintv.png │ │ │ └── launch_background.xml │ │ │ ├── mipmap-hdpi │ │ │ └── ic_launcher.png │ │ │ ├── mipmap-mdpi │ │ │ └── ic_launcher.png │ │ │ ├── mipmap-xhdpi │ │ │ └── ic_launcher.png │ │ │ ├── mipmap-xxhdpi │ │ │ └── ic_launcher.png │ │ │ ├── mipmap-xxxhdpi │ │ │ └── ic_launcher.png │ │ │ ├── values-night │ │ │ └── styles.xml │ │ │ └── values │ │ │ └── styles.xml │ │ └── profile │ │ └── AndroidManifest.xml ├── build.gradle ├── gradle.properties ├── gradle │ └── wrapper │ │ └── gradle-wrapper.properties └── settings.gradle ├── assets └── images │ ├── imdb.png │ ├── logo.svg │ ├── realdebrid.png │ ├── tmdb.png │ └── trakt.png ├── imdb_result.json ├── install_firetv.sh ├── ios ├── .gitignore ├── Flutter │ ├── AppFrameworkInfo.plist │ ├── Debug.xcconfig │ └── Release.xcconfig ├── Runner.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ ├── IDEWorkspaceChecks.plist │ │ │ └── WorkspaceSettings.xcsettings │ └── xcshareddata │ │ └── xcschemes │ │ └── Runner.xcscheme ├── Runner.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── WorkspaceSettings.xcsettings └── Runner │ ├── AppDelegate.swift │ ├── Assets.xcassets │ ├── AppIcon.appiconset │ │ ├── Contents.json │ │ ├── Icon-App-1024x1024@1x.png │ │ ├── Icon-App-20x20@1x.png │ │ ├── Icon-App-20x20@2x.png │ │ ├── Icon-App-20x20@3x.png │ │ ├── Icon-App-29x29@1x.png │ │ ├── Icon-App-29x29@2x.png │ │ ├── Icon-App-29x29@3x.png │ │ ├── Icon-App-40x40@1x.png │ │ ├── Icon-App-40x40@2x.png │ │ ├── Icon-App-40x40@3x.png │ │ ├── Icon-App-60x60@2x.png │ │ ├── Icon-App-60x60@3x.png │ │ ├── Icon-App-76x76@1x.png │ │ ├── Icon-App-76x76@2x.png │ │ └── Icon-App-83.5x83.5@2x.png │ └── LaunchImage.imageset │ │ ├── Contents.json │ │ ├── LaunchImage.png │ │ ├── LaunchImage@2x.png │ │ ├── LaunchImage@3x.png │ │ └── README.md │ ├── Base.lproj │ ├── LaunchScreen.storyboard │ └── Main.storyboard │ ├── Info.plist │ └── Runner-Bridging-Header.h ├── lib ├── controllers │ ├── app_controller.dart │ ├── base_controller.dart │ ├── detail_controller.dart │ ├── grid_controller.dart │ ├── home_controller.dart │ ├── settings_controller.dart │ ├── streams_controller.dart │ └── watched_controller.dart ├── data │ ├── entities │ │ ├── config.dart │ │ ├── imdb.dart │ │ ├── imdb.g.dart │ │ ├── realdebrid.dart │ │ ├── realdebrid.g.dart │ │ ├── scrape.dart │ │ ├── scrape.g.dart │ │ ├── tmdb.dart │ │ ├── tmdb.g.dart │ │ ├── trakt.dart │ │ ├── trakt.g.dart │ │ └── user.dart │ ├── models │ │ ├── auth_model.dart │ │ ├── item_model.dart │ │ └── settings_model.dart │ └── services │ │ ├── api.dart │ │ ├── db.dart │ │ ├── imdb_service.dart │ │ ├── mqtt.dart │ │ ├── scrape_service.dart │ │ ├── tmdb_service.dart │ │ └── trakt_service.dart ├── helpers.dart ├── main.dart ├── main.reflectable.dart ├── theme.dart └── ui │ ├── app.dart │ ├── appbar.dart │ ├── cover │ ├── animated.dart │ ├── backdrop_cover.dart │ ├── poster_cover.dart │ └── still_cover.dart │ ├── detail │ ├── detail.dart │ ├── detail_movie.dart │ ├── detail_show.dart │ ├── episodes.dart │ └── widgets.dart │ ├── dialogs │ ├── default.dart │ └── streams.dart │ ├── focusnodes.dart │ ├── login.dart │ ├── pages │ ├── grid.dart │ └── home.dart │ ├── selectbuttonfixer.dart │ ├── settings.dart │ ├── userselect.dart │ └── widgets │ ├── buttons.dart │ ├── carousel.dart │ ├── ensure_visible.dart │ ├── loaders.dart │ ├── section.dart │ └── widgets.dart ├── pubspec.lock ├── pubspec.yaml ├── screenshots ├── btc_donation.png ├── connect.png ├── odin-tv.png └── odin-tv2.png ├── test ├── widget_test.dart └── widget_test.reflectable.dart └── web ├── favicon.png ├── icons ├── Icon-192.png ├── Icon-512.png ├── Icon-maskable-192.png └── Icon-maskable-512.png ├── index.html └── manifest.json /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI - Build and push 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | concurrency: 9 | group: ${{ github.workflow }} 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | build-and-push: 14 | name: Build and push 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | - name: Log in to the Container registry 20 | uses: docker/login-action@v3 21 | with: 22 | registry: ghcr.io 23 | username: ${{ github.actor }} 24 | password: ${{ secrets.GITHUB_TOKEN }} 25 | - name: Clone repository 26 | uses: actions/checkout@v4 27 | - uses: actions/setup-java@v4 28 | with: 29 | distribution: "oracle" 30 | java-version: "17" 31 | - name: Set up Flutter 32 | uses: subosito/flutter-action@v2 33 | with: 34 | channel: stable 35 | cache: true 36 | - run: flutter build apk 37 | - name: "Rename" 38 | run: mv build/app/outputs/apk/release/app-release.apk build/app/outputs/apk/release/odin-tv_${{ github.ref_name }}.apk 39 | - name: "Add to release" 40 | uses: softprops/action-gh-release@v2 41 | with: 42 | files: | 43 | build/app/outputs/apk/release/odin-tv_${{ github.ref_name }}.apk 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Miscellaneous 3 | *.class 4 | *.log 5 | *.pyc 6 | *.swp 7 | .DS_Store 8 | .atom/ 9 | .buildlog/ 10 | .history 11 | .svn/ 12 | 13 | # IntelliJ related 14 | *.iml 15 | *.ipr 16 | *.iws 17 | .idea/ 18 | 19 | # The .vscode folder contains launch configuration and tasks you configure in 20 | # VS Code which you may wish to be included in version control, so this line 21 | # is commented out by default. 22 | #.vscode/ 23 | 24 | # Flutter/Dart/Pub related 25 | **/doc/api/ 26 | **/ios/Flutter/.last_build_id 27 | .dart_tool/ 28 | .flutter-plugins 29 | .flutter-plugins-dependencies 30 | .packages 31 | .pub-cache/ 32 | .pub/ 33 | /build/ 34 | 35 | # Web related 36 | 37 | # Symbolication related 38 | app.*.symbols 39 | 40 | # Obfuscation related 41 | app.*.map.json 42 | 43 | # Android Studio will place build artifacts here 44 | /android/app/debug 45 | /android/app/profile 46 | /android/app/release 47 | -------------------------------------------------------------------------------- /.metadata: -------------------------------------------------------------------------------- 1 | # This file tracks properties of this Flutter project. 2 | # Used by Flutter tool to assess capabilities and perform upgrades etc. 3 | # 4 | # This file should be version controlled and should not be manually edited. 5 | 6 | version: 7 | revision: "2663184aa79047d0a33a14a3b607954f8fdd8730" 8 | channel: "stable" 9 | 10 | project_type: app 11 | 12 | # Tracks metadata for the flutter migrate command 13 | migration: 14 | platforms: 15 | - platform: root 16 | create_revision: 2663184aa79047d0a33a14a3b607954f8fdd8730 17 | base_revision: 2663184aa79047d0a33a14a3b607954f8fdd8730 18 | - platform: web 19 | create_revision: 2663184aa79047d0a33a14a3b607954f8fdd8730 20 | base_revision: 2663184aa79047d0a33a14a3b607954f8fdd8730 21 | 22 | # User provided section 23 | 24 | # List of Local paths (relative to this file) that should be 25 | # ignored by the migrate tool. 26 | # 27 | # Files that are not part of the templates will be ignored by default. 28 | unmanaged_files: 29 | - 'lib/main.dart' 30 | - 'ios/Runner.xcodeproj/project.pbxproj' 31 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "odin (debug)", 6 | "request": "launch", 7 | "type": "dart", 8 | "flutterMode": "debug" 9 | }, 10 | { 11 | "name": "odin (profile mode)", 12 | "request": "launch", 13 | "type": "dart", 14 | "flutterMode": "profile" 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 |
3 |

4 | 5 |

Android TV app for Odin

6 | 7 | ![release](https://github.com/ad-on-is/odin-tv/actions/workflows/ci.yml/badge.svg?branch=) 8 | [![Version](https://img.shields.io/github/release/ad-on-is/odin-tv.svg?style=flat)]() 9 | [![GitHub stars](https://img.shields.io/github/stars/ad-on-is/odin-tv.svg?style=social&label=Stars)]() 10 | 11 | ![screenshot](./screenshots/odin-tv.png) 12 | ![screenshot2](./screenshots/odin-tv2.png) 13 | 14 | This is the Android TV app to enjoy Odin on your TV. 15 | 16 | # Installation 17 | 18 | - Install and setup the [Odin server](https://github.com/ad-on-is/odin-server) first 19 | - Download the latest APK from the [release](https://github.com/ad-on-is/odin-tv/releases) page 20 | - Sideload to your Android TV box 21 | - Follow the on-screen instructions 22 | 23 | ![connect](./screenshots/connect.png) 24 | 25 | ## Compatible devices 26 | 27 | - Xiaomi MiBox 28 | - Amazon Fire TV Cube 2/3 29 | - Other devices need testing and confirmation 30 | 31 | > [!WARNING] 32 | > 33 | > The app does not come with a built-in video player. See below. 34 | 35 | > 36 | 37 | ## Video Player 38 | 39 | Odin TV **does not include a video player**, since there are better players out there that support audio-passthrough, etc, it is not worth the hassle to reinvent the wheel. 40 | 41 | Recommended players: 42 | 43 | - Just (default) 44 | - Nova 45 | - Kodi 46 | - MX Player 47 | - VLC 48 | 49 | # Known issues 50 | 51 | - The navigation is a bit wonky. This is due to Flutters lack of keyboard and D-Pad navigation. I'm working my ass of to make this as smooth as possible. 52 | - If you experience issues, just smash the buttons on your remote. 53 | 54 | # Donations 55 | 56 | 57 | 58 | ## License 59 | 60 | GPLv3 61 | 62 | --- 63 | 64 | > GitHub [@ad-on-is](https://github.com/ad-on-is) 65 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | # This file configures the analyzer, which statically analyzes Dart code to 2 | # check for errors, warnings, and lints. 3 | # 4 | # The issues identified by the analyzer are surfaced in the UI of Dart-enabled 5 | # IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be 6 | # invoked from the command line by running `flutter analyze`. 7 | 8 | # The following line activates a set of recommended lints for Flutter apps, 9 | # packages, and plugins designed to encourage good coding practices. 10 | include: package:flutter_lints/flutter.yaml 11 | 12 | linter: 13 | # The lint rules applied to this project can be customized in the 14 | # section below to disable rules from the `package:flutter_lints/flutter.yaml` 15 | # included above or to enable additional rules. A list of all available lints 16 | # and their documentation is published at 17 | # https://dart-lang.github.io/linter/lints/index.html. 18 | # 19 | # Instead of disabling a lint rule for the entire project in the 20 | # section below, it can also be suppressed for a single line of code 21 | # or a specific dart file by using the `// ignore: name_of_lint` and 22 | # `// ignore_for_file: name_of_lint` syntax on the line or in the file 23 | # producing the lint. 24 | rules: 25 | # avoid_print: false # Uncomment to disable the `avoid_print` rule 26 | # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule 27 | 28 | # Additional information about this file can be found at 29 | # https://dart.dev/guides/language/analysis-options 30 | analyzer: 31 | exclude: 32 | - "**/*.g.dart" 33 | - "**/*.freezed.dart" 34 | errors: 35 | invalid_annotation_target: ignore -------------------------------------------------------------------------------- /android/.gitignore: -------------------------------------------------------------------------------- 1 | gradle-wrapper.jar 2 | /.gradle 3 | /captures/ 4 | /gradlew 5 | /gradlew.bat 6 | /local.properties 7 | GeneratedPluginRegistrant.java 8 | 9 | # Remember to never publicly share your keystore. 10 | # See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app 11 | key.properties 12 | **/*.keystore 13 | **/*.jks 14 | -------------------------------------------------------------------------------- /android/app/build.gradle: -------------------------------------------------------------------------------- 1 | def localProperties = new Properties() 2 | def localPropertiesFile = rootProject.file('local.properties') 3 | if (localPropertiesFile.exists()) { 4 | localPropertiesFile.withReader('UTF-8') { reader -> 5 | localProperties.load(reader) 6 | } 7 | } 8 | 9 | def flutterRoot = localProperties.getProperty('flutter.sdk') 10 | if (flutterRoot == null) { 11 | throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") 12 | } 13 | 14 | def flutterVersionCode = localProperties.getProperty('flutter.versionCode') 15 | if (flutterVersionCode == null) { 16 | flutterVersionCode = '1' 17 | } 18 | 19 | def flutterVersionName = localProperties.getProperty('flutter.versionName') 20 | if (flutterVersionName == null) { 21 | flutterVersionName = '1.0' 22 | } 23 | 24 | apply plugin: 'com.android.application' 25 | apply plugin: 'kotlin-android' 26 | apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" 27 | 28 | android { 29 | namespace "org.odintv.app" 30 | compileSdkVersion flutter.compileSdkVersion 31 | 32 | compileOptions { 33 | sourceCompatibility JavaVersion.VERSION_1_8 34 | targetCompatibility JavaVersion.VERSION_1_8 35 | } 36 | 37 | kotlinOptions { 38 | jvmTarget = '1.8' 39 | } 40 | 41 | sourceSets { 42 | main.java.srcDirs += 'src/main/kotlin' 43 | } 44 | 45 | defaultConfig { 46 | // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). 47 | applicationId "org.odintv.app" 48 | minSdkVersion flutter.minSdkVersion 49 | targetSdkVersion flutter.targetSdkVersion 50 | versionCode flutterVersionCode.toInteger() 51 | versionName flutterVersionName 52 | } 53 | 54 | buildTypes { 55 | release { 56 | // TODO: Add your own signing config for the release build. 57 | // Signing with the debug keys for now, so `flutter run --release` works. 58 | signingConfig signingConfigs.debug 59 | } 60 | } 61 | } 62 | 63 | flutter { 64 | source '../..' 65 | } 66 | 67 | dependencies { 68 | implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" 69 | } 70 | -------------------------------------------------------------------------------- /android/app/src/debug/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /android/app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 18 | 26 | 30 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 42 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /android/app/src/main/kotlin/com/example/odin/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package org.odintv.app 2 | 3 | import io.flutter.embedding.android.FlutterActivity 4 | 5 | class MainActivity: FlutterActivity() { 6 | } 7 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-v21/app_icon_odintv.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-on-is/odin-tv/2a84a609370b8cccdbd9ab3e0f578f63f4fa15f6/android/app/src/main/res/drawable-v21/app_icon_odintv.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-v21/launch_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 12 | 13 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/app_icon_odintv.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-on-is/odin-tv/2a84a609370b8cccdbd9ab3e0f578f63f4fa15f6/android/app/src/main/res/drawable/app_icon_odintv.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/launch_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 12 | 13 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-on-is/odin-tv/2a84a609370b8cccdbd9ab3e0f578f63f4fa15f6/android/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-on-is/odin-tv/2a84a609370b8cccdbd9ab3e0f578f63f4fa15f6/android/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-on-is/odin-tv/2a84a609370b8cccdbd9ab3e0f578f63f4fa15f6/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-on-is/odin-tv/2a84a609370b8cccdbd9ab3e0f578f63f4fa15f6/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-on-is/odin-tv/2a84a609370b8cccdbd9ab3e0f578f63f4fa15f6/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/values-night/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 15 | 18 | 19 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 15 | 18 | 19 | -------------------------------------------------------------------------------- /android/app/src/profile/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /android/build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | ext.kotlin_version = '1.9.24' 3 | repositories { 4 | google() 5 | mavenCentral() 6 | } 7 | 8 | dependencies { 9 | classpath 'com.android.tools.build:gradle:8.7.3' 10 | classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" 11 | } 12 | } 13 | 14 | allprojects { 15 | repositories { 16 | google() 17 | mavenCentral() 18 | } 19 | } 20 | 21 | rootProject.buildDir = '../build' 22 | subprojects { 23 | project.buildDir = "${rootProject.buildDir}/${project.name}" 24 | } 25 | subprojects { 26 | project.evaluationDependsOn(':app') 27 | } 28 | 29 | tasks.register("clean", Delete) { 30 | delete rootProject.buildDir 31 | } 32 | -------------------------------------------------------------------------------- /android/gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Xmx1536M 2 | android.useAndroidX=true 3 | android.enableJetifier=true 4 | -------------------------------------------------------------------------------- /android/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Fri Jun 23 08:50:38 CEST 2017 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-all.zip 7 | -------------------------------------------------------------------------------- /android/settings.gradle: -------------------------------------------------------------------------------- 1 | //include ':app' 2 | // 3 | //def localPropertiesFile = new File(rootProject.projectDir, "local.properties") 4 | //def properties = new Properties() 5 | // 6 | //assert localPropertiesFile.exists() 7 | //localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } 8 | // 9 | //def flutterSdkPath = properties.getProperty("flutter.sdk") 10 | //assert flutterSdkPath != null, "flutter.sdk not set in local.properties" 11 | //apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" 12 | // 13 | 14 | pluginManagement { 15 | def flutterSdkPath = { 16 | def properties = new Properties() 17 | file("local.properties").withInputStream { properties.load(it) } 18 | def flutterSdkPath = properties.getProperty("flutter.sdk") 19 | assert flutterSdkPath != null, "flutter.sdk not set in local.properties" 20 | return flutterSdkPath 21 | } 22 | settings.ext.flutterSdkPath = flutterSdkPath() 23 | 24 | includeBuild("${settings.ext.flutterSdkPath}/packages/flutter_tools/gradle") 25 | 26 | repositories { 27 | google() 28 | mavenCentral() 29 | gradlePluginPortal() 30 | } 31 | } 32 | 33 | plugins { 34 | id "dev.flutter.flutter-plugin-loader" version "1.0.0" 35 | id "com.android.application" version "8.7.3" apply false 36 | id "org.jetbrains.kotlin.android" version "1.9.24" apply false 37 | } 38 | 39 | include ":app" 40 | -------------------------------------------------------------------------------- /assets/images/imdb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-on-is/odin-tv/2a84a609370b8cccdbd9ab3e0f578f63f4fa15f6/assets/images/imdb.png -------------------------------------------------------------------------------- /assets/images/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 17 | 19 | 29 | 32 | 36 | 40 | 41 | 42 | 56 | 59 | 66 | 73 | 80 | 81 | 82 | -------------------------------------------------------------------------------- /assets/images/realdebrid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-on-is/odin-tv/2a84a609370b8cccdbd9ab3e0f578f63f4fa15f6/assets/images/realdebrid.png -------------------------------------------------------------------------------- /assets/images/tmdb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-on-is/odin-tv/2a84a609370b8cccdbd9ab3e0f578f63f4fa15f6/assets/images/tmdb.png -------------------------------------------------------------------------------- /assets/images/trakt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-on-is/odin-tv/2a84a609370b8cccdbd9ab3e0f578f63f4fa15f6/assets/images/trakt.png -------------------------------------------------------------------------------- /imdb_result.json: -------------------------------------------------------------------------------- 1 | { 2 | "@context": "https://schema.org", 3 | "@type": "Movie", 4 | "url": "/title/tt4513678/", 5 | "name": "Ghostbusters: Afterlife", 6 | "alternateName": "Ghostbusters: Legacy", 7 | "image": "https://m.media-amazon.com/images/M/MV5BMmZiMjdlN2UtYzdiZS00YjgxLTgyZGMtYzE4ZGU5NTlkNjhhXkEyXkFqcGdeQXVyMTEyMjM2NDc2._V1_.jpg", 8 | "description": "When a single mom and her two kids arrive in a small town, they begin to discover their connection to the original Ghostbusters and the secret legacy their grandfather left behind.", 9 | "review": { 10 | "@type": "Review", 11 | "itemReviewed": { "@type": "CreativeWork", "url": "/title/tt4513678/" }, 12 | "author": { "@type": "Person", "name": "samuelelegolasfilippi" }, 13 | "dateCreated": "2021-11-01", 14 | "inLanguage": "English", 15 | "name": "The closing and the beginning", 16 | "reviewBody": "I saw it preview last night. A film that in some points recalls the first Gostbuster, but which has its own ideology. The characters they present to us are almost all well characterized and useful to the plot. The director did a good job, given the great responsibility he had with this film. Some ideas are very bright and innovative. The first hour and a half is very beautiful, full of jokes and suspenseful scenes, the problem is the last half hour that is lost a bit by recalling the first film a lot. The soundtrack is irrelevant, apart from those two moments when you hear the original themes. Overall it is a good film, not at the levels of the first but certainly superior to the female Gostbuster.", 17 | "reviewRating": { 18 | "@type": "Rating", 19 | "worstRating": 1, 20 | "bestRating": 10, 21 | "ratingValue": 7 22 | } 23 | }, 24 | "aggregateRating": { 25 | "@type": "AggregateRating", 26 | "ratingCount": 99525, 27 | "bestRating": 10, 28 | "worstRating": 1, 29 | "ratingValue": 7.3 30 | }, 31 | "contentRating": "PG-13", 32 | "genre": ["Adventure", "Comedy", "Fantasy"], 33 | "datePublished": "2021-11-19", 34 | "keywords": "sequel,ghost,ghostbusters,ghostbuster,teenager", 35 | "trailer": { 36 | "@type": "VideoObject", 37 | "name": "Official Trailer", 38 | "embedUrl": "/video/imdb/vi637322009", 39 | "thumbnail": { 40 | "@type": "ImageObject", 41 | "contentUrl": "https://m.media-amazon.com/images/M/MV5BYWFhZDk5NjEtNmU4OS00NGQyLTk2N2MtNTk5YWE5NDQ2ZjcyXkEyXkFqcGdeQWRvb2xpbmhk._V1_.jpg" 42 | }, 43 | "thumbnailUrl": "https://m.media-amazon.com/images/M/MV5BYWFhZDk5NjEtNmU4OS00NGQyLTk2N2MtNTk5YWE5NDQ2ZjcyXkEyXkFqcGdeQWRvb2xpbmhk._V1_.jpg", 44 | "description": "Afterlife, when a single mom and her two kids arrive in a small town, they begin to discover their connection to the original ghostbusters and the secret legacy their grandfather left behind." 45 | }, 46 | "actor": [ 47 | { "@type": "Person", "url": "/name/nm4689420/", "name": "Carrie Coon" }, 48 | { "@type": "Person", "url": "/name/nm0748620/", "name": "Paul Rudd" }, 49 | { "@type": "Person", "url": "/name/nm6016511/", "name": "Finn Wolfhard" } 50 | ], 51 | "director": [ 52 | { "@type": "Person", "url": "/name/nm0718646/", "name": "Jason Reitman" } 53 | ], 54 | "creator": [ 55 | { "@type": "Organization", "url": "/company/co0050868/" }, 56 | { "@type": "Organization", "url": "/company/co0309252/" }, 57 | { "@type": "Organization", "url": "/company/co0526864/" }, 58 | { "@type": "Person", "url": "/name/nm1481493/", "name": "Gil Kenan" }, 59 | { "@type": "Person", "url": "/name/nm0718646/", "name": "Jason Reitman" }, 60 | { "@type": "Person", "url": "/name/nm0000101/", "name": "Dan Aykroyd" } 61 | ], 62 | "duration": "PT2H4M" 63 | } 64 | -------------------------------------------------------------------------------- /install_firetv.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | adb connect $(landevice Fire):5555 && adb -s $(landevice Fire):5555 install build/app/outputs/flutter-apk/app-release.apk -------------------------------------------------------------------------------- /ios/.gitignore: -------------------------------------------------------------------------------- 1 | **/dgph 2 | *.mode1v3 3 | *.mode2v3 4 | *.moved-aside 5 | *.pbxuser 6 | *.perspectivev3 7 | **/*sync/ 8 | .sconsign.dblite 9 | .tags* 10 | **/.vagrant/ 11 | **/DerivedData/ 12 | Icon? 13 | **/Pods/ 14 | **/.symlinks/ 15 | profile 16 | xcuserdata 17 | **/.generated/ 18 | Flutter/App.framework 19 | Flutter/Flutter.framework 20 | Flutter/Flutter.podspec 21 | Flutter/Generated.xcconfig 22 | Flutter/ephemeral/ 23 | Flutter/app.flx 24 | Flutter/app.zip 25 | Flutter/flutter_assets/ 26 | Flutter/flutter_export_environment.sh 27 | ServiceDefinitions.json 28 | Runner/GeneratedPluginRegistrant.* 29 | 30 | # Exceptions to above rules. 31 | !default.mode1v3 32 | !default.mode2v3 33 | !default.pbxuser 34 | !default.perspectivev3 35 | -------------------------------------------------------------------------------- /ios/Flutter/AppFrameworkInfo.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | App 9 | CFBundleIdentifier 10 | io.flutter.flutter.app 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | App 15 | CFBundlePackageType 16 | FMWK 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1.0 23 | MinimumOSVersion 24 | 9.0 25 | 26 | 27 | -------------------------------------------------------------------------------- /ios/Flutter/Debug.xcconfig: -------------------------------------------------------------------------------- 1 | #include "Generated.xcconfig" 2 | -------------------------------------------------------------------------------- /ios/Flutter/Release.xcconfig: -------------------------------------------------------------------------------- 1 | #include "Generated.xcconfig" 2 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreviewsEnabled 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 37 | 38 | 39 | 40 | 41 | 42 | 52 | 54 | 60 | 61 | 62 | 63 | 69 | 71 | 77 | 78 | 79 | 80 | 82 | 83 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreviewsEnabled 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Flutter 3 | 4 | @UIApplicationMain 5 | @objc class AppDelegate: FlutterAppDelegate { 6 | override func application( 7 | _ application: UIApplication, 8 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? 9 | ) -> Bool { 10 | GeneratedPluginRegistrant.register(with: self) 11 | return super.application(application, didFinishLaunchingWithOptions: launchOptions) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "size" : "20x20", 5 | "idiom" : "iphone", 6 | "filename" : "Icon-App-20x20@2x.png", 7 | "scale" : "2x" 8 | }, 9 | { 10 | "size" : "20x20", 11 | "idiom" : "iphone", 12 | "filename" : "Icon-App-20x20@3x.png", 13 | "scale" : "3x" 14 | }, 15 | { 16 | "size" : "29x29", 17 | "idiom" : "iphone", 18 | "filename" : "Icon-App-29x29@1x.png", 19 | "scale" : "1x" 20 | }, 21 | { 22 | "size" : "29x29", 23 | "idiom" : "iphone", 24 | "filename" : "Icon-App-29x29@2x.png", 25 | "scale" : "2x" 26 | }, 27 | { 28 | "size" : "29x29", 29 | "idiom" : "iphone", 30 | "filename" : "Icon-App-29x29@3x.png", 31 | "scale" : "3x" 32 | }, 33 | { 34 | "size" : "40x40", 35 | "idiom" : "iphone", 36 | "filename" : "Icon-App-40x40@2x.png", 37 | "scale" : "2x" 38 | }, 39 | { 40 | "size" : "40x40", 41 | "idiom" : "iphone", 42 | "filename" : "Icon-App-40x40@3x.png", 43 | "scale" : "3x" 44 | }, 45 | { 46 | "size" : "60x60", 47 | "idiom" : "iphone", 48 | "filename" : "Icon-App-60x60@2x.png", 49 | "scale" : "2x" 50 | }, 51 | { 52 | "size" : "60x60", 53 | "idiom" : "iphone", 54 | "filename" : "Icon-App-60x60@3x.png", 55 | "scale" : "3x" 56 | }, 57 | { 58 | "size" : "20x20", 59 | "idiom" : "ipad", 60 | "filename" : "Icon-App-20x20@1x.png", 61 | "scale" : "1x" 62 | }, 63 | { 64 | "size" : "20x20", 65 | "idiom" : "ipad", 66 | "filename" : "Icon-App-20x20@2x.png", 67 | "scale" : "2x" 68 | }, 69 | { 70 | "size" : "29x29", 71 | "idiom" : "ipad", 72 | "filename" : "Icon-App-29x29@1x.png", 73 | "scale" : "1x" 74 | }, 75 | { 76 | "size" : "29x29", 77 | "idiom" : "ipad", 78 | "filename" : "Icon-App-29x29@2x.png", 79 | "scale" : "2x" 80 | }, 81 | { 82 | "size" : "40x40", 83 | "idiom" : "ipad", 84 | "filename" : "Icon-App-40x40@1x.png", 85 | "scale" : "1x" 86 | }, 87 | { 88 | "size" : "40x40", 89 | "idiom" : "ipad", 90 | "filename" : "Icon-App-40x40@2x.png", 91 | "scale" : "2x" 92 | }, 93 | { 94 | "size" : "76x76", 95 | "idiom" : "ipad", 96 | "filename" : "Icon-App-76x76@1x.png", 97 | "scale" : "1x" 98 | }, 99 | { 100 | "size" : "76x76", 101 | "idiom" : "ipad", 102 | "filename" : "Icon-App-76x76@2x.png", 103 | "scale" : "2x" 104 | }, 105 | { 106 | "size" : "83.5x83.5", 107 | "idiom" : "ipad", 108 | "filename" : "Icon-App-83.5x83.5@2x.png", 109 | "scale" : "2x" 110 | }, 111 | { 112 | "size" : "1024x1024", 113 | "idiom" : "ios-marketing", 114 | "filename" : "Icon-App-1024x1024@1x.png", 115 | "scale" : "1x" 116 | } 117 | ], 118 | "info" : { 119 | "version" : 1, 120 | "author" : "xcode" 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-on-is/odin-tv/2a84a609370b8cccdbd9ab3e0f578f63f4fa15f6/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-on-is/odin-tv/2a84a609370b8cccdbd9ab3e0f578f63f4fa15f6/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-on-is/odin-tv/2a84a609370b8cccdbd9ab3e0f578f63f4fa15f6/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-on-is/odin-tv/2a84a609370b8cccdbd9ab3e0f578f63f4fa15f6/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-on-is/odin-tv/2a84a609370b8cccdbd9ab3e0f578f63f4fa15f6/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-on-is/odin-tv/2a84a609370b8cccdbd9ab3e0f578f63f4fa15f6/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-on-is/odin-tv/2a84a609370b8cccdbd9ab3e0f578f63f4fa15f6/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-on-is/odin-tv/2a84a609370b8cccdbd9ab3e0f578f63f4fa15f6/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-on-is/odin-tv/2a84a609370b8cccdbd9ab3e0f578f63f4fa15f6/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-on-is/odin-tv/2a84a609370b8cccdbd9ab3e0f578f63f4fa15f6/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-on-is/odin-tv/2a84a609370b8cccdbd9ab3e0f578f63f4fa15f6/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-on-is/odin-tv/2a84a609370b8cccdbd9ab3e0f578f63f4fa15f6/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-on-is/odin-tv/2a84a609370b8cccdbd9ab3e0f578f63f4fa15f6/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-on-is/odin-tv/2a84a609370b8cccdbd9ab3e0f578f63f4fa15f6/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-on-is/odin-tv/2a84a609370b8cccdbd9ab3e0f578f63f4fa15f6/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "LaunchImage.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "LaunchImage@2x.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "LaunchImage@3x.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-on-is/odin-tv/2a84a609370b8cccdbd9ab3e0f578f63f4fa15f6/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-on-is/odin-tv/2a84a609370b8cccdbd9ab3e0f578f63f4fa15f6/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-on-is/odin-tv/2a84a609370b8cccdbd9ab3e0f578f63f4fa15f6/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md: -------------------------------------------------------------------------------- 1 | # Launch Screen Assets 2 | 3 | You can customize the launch screen with your own desired assets by replacing the image files in this directory. 4 | 5 | You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. -------------------------------------------------------------------------------- /ios/Runner/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /ios/Runner/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /ios/Runner/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleDisplayName 8 | Odin 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | odin 17 | CFBundlePackageType 18 | APPL 19 | CFBundleShortVersionString 20 | $(FLUTTER_BUILD_NAME) 21 | CFBundleSignature 22 | ???? 23 | CFBundleVersion 24 | $(FLUTTER_BUILD_NUMBER) 25 | LSRequiresIPhoneOS 26 | 27 | UILaunchStoryboardName 28 | LaunchScreen 29 | UIMainStoryboardFile 30 | Main 31 | UISupportedInterfaceOrientations 32 | 33 | UIInterfaceOrientationPortrait 34 | UIInterfaceOrientationLandscapeLeft 35 | UIInterfaceOrientationLandscapeRight 36 | 37 | UISupportedInterfaceOrientations~ipad 38 | 39 | UIInterfaceOrientationPortrait 40 | UIInterfaceOrientationPortraitUpsideDown 41 | UIInterfaceOrientationLandscapeLeft 42 | UIInterfaceOrientationLandscapeRight 43 | 44 | UIViewControllerBasedStatusBarAppearance 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /ios/Runner/Runner-Bridging-Header.h: -------------------------------------------------------------------------------- 1 | #import "GeneratedPluginRegistrant.h" 2 | -------------------------------------------------------------------------------- /lib/controllers/app_controller.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 | import 'package:odin/data/entities/trakt.dart'; 4 | import 'package:odin/ui/pages/grid.dart'; 5 | import 'package:odin/ui/pages/home.dart'; 6 | 7 | final List pages = [ 8 | const HomePage(), 9 | const MoviesGrid(), 10 | const ShowsGrid() 11 | ]; 12 | 13 | final appPageProvider = StateProvider((ref) => 0); 14 | final selectedItemProvider = StateProvider((ref) => Trakt()); 15 | final selectedSectionProvider = StateProvider((ref) => ""); 16 | final selectedItemOfSectionProvider = StateProviderFamily( 17 | (ref, section) => Trakt(), 18 | ); 19 | final bgBusyProvider = StateProvider((ref) => false); 20 | final appBusyProvider = StateProvider((ref) => false); 21 | final bgAlpha = StateProvider((ref) => 230); 22 | final debugProvider = StateProvider((ref) => "test"); 23 | 24 | final beforeFocusProvider = StateProvider((ref) => false); 25 | final beforeFocusHasFocus = StateProvider((ref) => true); 26 | final afterFocusProvider = StateProvider((ref) => false); 27 | -------------------------------------------------------------------------------- /lib/controllers/base_controller.dart: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /lib/controllers/detail_controller.dart: -------------------------------------------------------------------------------- 1 | import 'package:android_intent_plus/android_intent.dart'; 2 | import 'package:android_intent_plus/flag.dart'; 3 | import 'package:flutter/material.dart'; 4 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 5 | import 'package:odin/data/entities/trakt.dart'; 6 | import 'package:odin/data/models/auth_model.dart'; 7 | import 'package:odin/data/services/mqtt.dart'; 8 | import 'package:odin/data/services/trakt_service.dart'; 9 | import 'package:odin/helpers.dart'; 10 | 11 | import '../data/services/tmdb_service.dart'; 12 | 13 | class DetailController extends StateNotifier with BaseHelper { 14 | Trakt? item; 15 | final Ref ref; 16 | int episode = 0; 17 | int season = 0; 18 | List seasons = []; 19 | final TraktService traktService; 20 | final TmdbService tmdbService; 21 | final AuthModel auth; 22 | Map> episodeImages = {}; 23 | DetailController(this.ref, this.traktService, this.tmdbService, this.auth) 24 | : super(false); 25 | final FocusNode playButtonNode = FocusNode(); 26 | final seasonButtonNode = FocusNode(); 27 | // Worker? seasonWorker; 28 | void onInit(Trakt i) async { 29 | item = i; 30 | if (item?.type == 'movie') { 31 | logInfo("Detail: ${item?.type}"); 32 | playButtonNode.requestFocus(); 33 | } else { 34 | seasonButtonNode.requestFocus(); 35 | logInfo("${item?.type ?? "häää"} - ${item!.title}"); 36 | seasons.addAll(await traktService.getSeasons(item!.ids.trakt)); 37 | logInfo(seasons.length.toString()); 38 | state = !state; 39 | getEpisodeImages(); 40 | } 41 | ref.read(mqttProvider).disConnectMQTT(); 42 | // seasonWorker = ever(season, (_) { 43 | // getEpisodeImages(season.value); 44 | // }); 45 | 46 | // state = !state; 47 | } 48 | 49 | Future getTraktSeasons() async {} 50 | 51 | void getEpisodeImages() async { 52 | episodeImages = await tmdbService.getEpisodeImages( 53 | item?.ids.tmdb ?? 0, seasons.map((s) => s.number).toList()); 54 | state = !state; 55 | } 56 | 57 | Future playTrailer() async { 58 | const intent = AndroidIntent( 59 | action: 'action_view', 60 | package: "com.teamsmart.videomanager.tv", 61 | data: "https://youtube.com/watch?v=9vN6DHB6bJc", 62 | arguments: { 63 | 'force_fullscreen': 'true', 64 | 'finish_on_end': 'true', 65 | }, 66 | flags: [ 67 | Flag.FLAG_ACTIVITY_MULTIPLE_TASK, 68 | Flag.FLAG_ACTIVITY_NO_HISTORY, 69 | ], 70 | ); 71 | await intent.launch(); 72 | } 73 | 74 | String getEpisodeImage(int season, int episode) { 75 | const String url = 'https://image.tmdb.org/t/p/w185'; 76 | if (episodeImages[season] != null && 77 | episodeImages[season]![episode] != null) { 78 | return url + episodeImages[season]![episode]!; 79 | } 80 | return url; 81 | } 82 | 83 | void onClose() { 84 | // seasonWorker?.dispose(); 85 | // super.onClose(); 86 | } 87 | 88 | void setSeason(int s) { 89 | season = s; 90 | episode = 0; 91 | state = !state; 92 | } 93 | 94 | void setEpisode(int e) async { 95 | episode = e; 96 | state = !state; 97 | } 98 | } 99 | 100 | final detailController = StateNotifierProvider.autoDispose((ref) => 101 | DetailController(ref, ref.watch(traktProvider), ref.watch(tmdbProvider), 102 | ref.watch(authProvider.notifier))); 103 | 104 | final selectedSeasonProvider = StateProvider((ref) => 0); 105 | -------------------------------------------------------------------------------- /lib/controllers/grid_controller.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 2 | import 'package:odin/data/models/item_model.dart'; 3 | import 'package:odin/data/services/api.dart'; 4 | import 'package:odin/data/services/trakt_service.dart'; 5 | 6 | final gridSectionProvider = FutureProvider.family 7 | .autoDispose, String>((ref, type) async { 8 | ref.watch(watchedProvider); 9 | final api = ref.watch(apiProvider); 10 | final u = await api.get('/-/user'); 11 | 12 | if (u.isLeft()) { 13 | return []; 14 | } 15 | 16 | return List.from( 17 | u.getRight().fold(() => null, (r) => r)["trakt_sections"][type]) 18 | .map((e) => SectionItem( 19 | title: e["title"], 20 | url: e["url"], 21 | paginate: e["paginate"] ?? false, 22 | big: e["big"] ?? false)) 23 | .toList() 24 | ..add(SectionItem(title: 'GENRE', url: '', type: type, isGenre: true)) 25 | ..toList(); 26 | 27 | // var year = DateTime.now().year; 28 | // final sections = [ 29 | // SectionItem( 30 | // title: 'MOST WATCHED TODAY', 31 | // url: '/$type/watched/daily', 32 | // big: true, 33 | // ), 34 | // SectionItem( 35 | // title: 'POPULAR $year/${year - 1} RELEASES', 36 | // url: '/$type/watched/weekly?years=$year,${year - 1}', 37 | // big: true, 38 | // ), 39 | // ]; 40 | // if (type == 'movies') { 41 | // sections.add(SectionItem( 42 | // title: 'BOX OFFICE', 43 | // url: '/$type/boxoffice', 44 | // paginate: false, 45 | // )); 46 | // } 47 | 48 | // if (type == 'shows') { 49 | // sections.add(SectionItem( 50 | // title: 'MOST POPULAR', 51 | // url: '/$type/popular', 52 | // )); 53 | // } 54 | 55 | // sections.add(SectionItem( 56 | // title: 'YOUR WATCHLIST', 57 | // url: '/sync/watchlist/$type/title', 58 | // )); 59 | 60 | // sections.add( 61 | // SectionItem( 62 | // title: 'HIGHLY ANTICIPATED', 63 | // url: '/$type/anticipated', 64 | // ), 65 | // ); 66 | // return sections; 67 | }); 68 | -------------------------------------------------------------------------------- /lib/controllers/home_controller.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 2 | import 'package:odin/data/models/item_model.dart'; 3 | import 'package:odin/data/services/api.dart'; 4 | import 'package:odin/data/services/trakt_service.dart'; 5 | 6 | final homeSectionProvider = 7 | FutureProvider.autoDispose>((ref) async { 8 | ref.watch(watchedProvider); 9 | final api = ref.watch(apiProvider); 10 | final u = await api.get('/-/user'); 11 | 12 | if (u.isLeft()) { 13 | return []; 14 | } 15 | 16 | return List.from( 17 | u.getRight().fold(() => null, (r) => r)["trakt_sections"]["home"]) 18 | .map((e) => SectionItem( 19 | title: e["title"], 20 | url: e["url"], 21 | paginate: e["paginate"] ?? false, 22 | big: e["big"] ?? false)) 23 | .toList(); 24 | }); 25 | -------------------------------------------------------------------------------- /lib/controllers/settings_controller.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 2 | import 'package:odin/data/entities/config.dart'; 3 | import 'package:odin/data/models/settings_model.dart'; 4 | 5 | class SettingsController extends StateNotifier { 6 | final Ref ref; 7 | 8 | SettingsModel settings; 9 | Config get config => settings.config; 10 | 11 | SettingsController(this.ref, this.settings) : super(false) { 12 | // auth = ref!.watch(authProvider); 13 | } 14 | 15 | void save() { 16 | settings.save(); 17 | // state = !state; 18 | } 19 | } 20 | 21 | final settingsController = StateNotifierProvider( 22 | (ref) => SettingsController(ref, ref.watch(settingsProvider))); 23 | -------------------------------------------------------------------------------- /lib/controllers/streams_controller.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 5 | import 'package:android_intent_plus/android_intent.dart'; 6 | import 'package:helpers/helpers.dart'; 7 | import 'package:mqtt_client/mqtt_client.dart'; 8 | import 'package:odin/controllers/detail_controller.dart'; 9 | import 'package:odin/data/entities/scrape.dart'; 10 | import 'package:odin/data/entities/trakt.dart'; 11 | import 'package:odin/data/models/settings_model.dart'; 12 | import 'package:odin/data/services/api.dart'; 13 | import 'package:odin/data/services/mqtt.dart'; 14 | import 'package:odin/data/services/scrape_service.dart'; 15 | import 'package:odin/data/services/trakt_service.dart'; 16 | import 'package:odin/helpers.dart'; 17 | import 'package:odin/ui/dialogs/default.dart'; 18 | 19 | class StreamUrl { 20 | String filename; 21 | int filesize; 22 | String download; 23 | List info; 24 | String quality; 25 | 26 | StreamUrl( 27 | this.filename, this.filesize, this.download, this.info, this.quality); 28 | } 29 | 30 | class StreamsController extends StateNotifier with BaseHelper { 31 | ScrapeService scrapeService; 32 | ApiService api; 33 | MQTTService mqtt; 34 | TraktService traktService; 35 | DetailController detail; 36 | SettingsModel settings; 37 | Map get player => settings.player; 38 | final Ref ref; 39 | bool inited = false; 40 | 41 | Trakt? item; 42 | Trakt? show; 43 | Trakt? season; 44 | bool confirmed = false; 45 | String status = "Scraping"; 46 | 47 | String playerTitle = ''; 48 | 49 | Map> scrapes = { 50 | 'HDR': [], 51 | '4K': [], 52 | '1080p': [], 53 | '720p': [], 54 | 'SD': [], 55 | 'CAM': [] 56 | }; 57 | 58 | StreamsController(this.ref, this.api, this.mqtt, this.scrapeService, 59 | this.traktService, this.settings, this.detail) 60 | : super(false); 61 | 62 | void init(Trakt item, Trakt? show, Trakt? season) async { 63 | inited = true; 64 | this.item = item; 65 | this.show = show; 66 | this.season = season; 67 | if (item.type == 'movie') { 68 | playerTitle = '${item.title} (${item.year})'; 69 | } else { 70 | playerTitle = 71 | '${show?.title} (${show?.year}) - S${season?.number}E${item.number} - ${item.title}'; 72 | } 73 | 74 | getUrls(); 75 | } 76 | 77 | // test 78 | 79 | DateTime? startPlay; 80 | DateTime? endPlay; 81 | 82 | Future confirmProbablyWatched(int percent, BuildContext ctx) async { 83 | return await showDialog( 84 | context: ctx, 85 | builder: (dctx) => DefaultDialog( 86 | child: Padding( 87 | padding: const EdgeInsets.all(20.0), 88 | child: Column( 89 | mainAxisSize: MainAxisSize.min, 90 | children: [ 91 | Headline3('You only watched $percent%'), 92 | const BodyText1('Do you want to mark it as watched?'), 93 | const SizedBox(height: 20), 94 | Row( 95 | mainAxisSize: MainAxisSize.min, 96 | children: [ 97 | TextButton( 98 | child: const BodyText1('Yes, mark as watched'), 99 | onPressed: () { 100 | confirmed = true; 101 | Navigator.of(dctx).pop(); 102 | }, 103 | ), 104 | const SizedBox(width: 20), 105 | TextButton( 106 | child: const BodyText1('No, I will watch later'), 107 | onPressed: () { 108 | confirmed = false; 109 | Navigator.of(dctx).pop(); 110 | }, 111 | ) 112 | ], 113 | ) 114 | ], 115 | ), 116 | ), 117 | )); 118 | } 119 | 120 | // // invoked when coming back from player 121 | void checkInTrakt(BuildContext ctx) async { 122 | if (startPlay == null) return; 123 | 124 | endPlay = DateTime.now(); 125 | final duration = endPlay!.difference(startPlay!); 126 | double p = 100 / item!.runtime * duration.inMinutes; 127 | int progress = p.round(); 128 | startPlay = null; 129 | if (progress < 0) progress = 0; 130 | if (progress >= 75) { 131 | progress = 100; 132 | traktService.setWatched(item: item!, show: show, season: season); 133 | } else { 134 | await confirmProbablyWatched(progress, ctx); 135 | if (confirmed) { 136 | progress = 100; 137 | traktService.setWatched(item: item!, show: show, season: season); 138 | } 139 | } 140 | traktService.watching(item!, progress, 'stop'); 141 | detail.state = !detail.state; 142 | } 143 | 144 | void getUrls({bool cache = true}) async { 145 | logWarning("getting Urls"); 146 | String topic = 'odin-movieshow/movie/${item!.ids.trakt}'; 147 | if (show != null) { 148 | topic = 'odin-movieshow/episode/${item!.ids.trakt}'; 149 | } 150 | await mqtt.initializeMQTTClient(topic); 151 | mqtt.client.updates!.listen((dynamic t) { 152 | final MqttPublishMessage recMess = t[0].payload; 153 | final message = 154 | MqttPublishPayload.bytesToStringAsString(recMess.payload.message); 155 | if (message == "SCRAPING_DONE") { 156 | status = "Done"; 157 | state = !state; 158 | return; 159 | } 160 | final s = Scrape.fromJson(json.decode(message)); 161 | String q = s.quality; 162 | if (q == '4K' && (s.info.contains('HDR') || s.info.contains('DV'))) { 163 | q = 'HDR'; 164 | } 165 | // logInfo(s.title); 166 | scrapes[q]!.add(s); 167 | state = !state; 168 | }); 169 | 170 | await scrapeService.scrape( 171 | item: item!, show: show, season: season, doCache: cache); 172 | 173 | await Future.delayed(const Duration(seconds: 1)); 174 | state = !state; 175 | } 176 | 177 | List allUrls() { 178 | List all = []; 179 | 180 | scrapes.forEach((q, values) { 181 | for (var s in values) { 182 | for (var link in s.links) { 183 | var su = StreamUrl( 184 | link["filename"], link["filesize"], link["download"], s.info, q); 185 | all.add(su); 186 | } 187 | } 188 | }); 189 | 190 | return all; 191 | } 192 | 193 | void openPlayer(String url) async { 194 | _launchPlayer(url); 195 | } 196 | 197 | void _startWatching() { 198 | startPlay = DateTime.now(); 199 | traktService.watching(item!, 0, 'start'); 200 | } 201 | 202 | void _launchPlayer(String url) async { 203 | final intent = AndroidIntent( 204 | action: 'action_view', 205 | package: player['id'], // com.mxtech.videoplayer.pro 206 | type: 'video/*', 207 | data: url, 208 | arguments: {'title': playerTitle}, 209 | ); 210 | _startWatching(); 211 | await intent.launch(); 212 | } 213 | } 214 | 215 | final streamsController = StateNotifierProvider.autoDispose((ref) { 216 | final mqtt = ref.watch(mqttProvider); 217 | ref.onDispose(() { 218 | mqtt.client.disconnect(); 219 | }); 220 | return StreamsController( 221 | ref, 222 | ref.watch(apiProvider), 223 | mqtt, 224 | ref.watch(scrapeProvider), 225 | ref.watch(traktProvider), 226 | ref.watch(settingsProvider), 227 | ref.watch(detailController.notifier)); 228 | }); 229 | -------------------------------------------------------------------------------- /lib/controllers/watched_controller.dart: -------------------------------------------------------------------------------- 1 | 2 | 3 | class WatchedController { 4 | // LazyBox hiveWatched = Get.find(tag: 'hive-watched'); 5 | // Rx watched = false.obs; 6 | // final Key key; 7 | // String boxkey = ''; 8 | // Worker? listener; 9 | 10 | // WatchedController(this.key) { 11 | // boxkey = key.toString().replaceAll("[<'", "").replaceAll("'>]", ""); 12 | // } 13 | 14 | // @override 15 | // void onInit() { 16 | // listener = ever(watched, (_) { 17 | // update(); 18 | // }); 19 | // getWatched(); 20 | // super.onInit(); 21 | // } 22 | 23 | // void getWatched() async { 24 | // watched(await hiveWatched.get(boxkey)); 25 | // } 26 | 27 | // @override 28 | // void onClose() { 29 | // listener?.dispose(); 30 | // super.onClose(); 31 | // } 32 | } 33 | -------------------------------------------------------------------------------- /lib/data/entities/config.dart: -------------------------------------------------------------------------------- 1 | class Config { 2 | String player; 3 | bool scrobble; 4 | Config({this.player = 'Just', this.scrobble = true}); 5 | } 6 | -------------------------------------------------------------------------------- /lib/data/entities/imdb.dart: -------------------------------------------------------------------------------- 1 | import 'package:json_annotation/json_annotation.dart'; 2 | part 'imdb.g.dart'; 3 | 4 | @JsonSerializable() 5 | class Imdb { 6 | double rating; 7 | int votes; 8 | double lastRating; 9 | String lastComment; 10 | String thumbUrl; 11 | 12 | Imdb( 13 | {this.rating = 0, 14 | this.votes = 0, 15 | this.lastRating = 0, 16 | this.lastComment = '', 17 | this.thumbUrl = ''}); 18 | 19 | factory Imdb.fromJson(Map json) { 20 | return Imdb( 21 | rating: json['aggregateRating'] != null 22 | ? json['aggregateRating']['ratingValue'] * 1.0 23 | : 0.0, 24 | votes: json['aggregateRating'] != null 25 | ? json['aggregateRating']['ratingCount'] 26 | : 0, 27 | thumbUrl: 28 | json['trailer'] != null ? json['trailer']['thumbnailUrl'] : '', 29 | lastComment: 30 | json['review'] != null && json['review']['reviewBody'] != null 31 | ? json['review']['reviewBody'] 32 | : '', 33 | lastRating: 34 | json['review'] != null && json['review']['reviewRating'] != null 35 | ? json['review']['reviewRating']['ratingValue'] * 1.0 36 | : 0.0); 37 | } 38 | 39 | Map toJson() => _$ImdbToJson(this); 40 | } 41 | -------------------------------------------------------------------------------- /lib/data/entities/imdb.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'imdb.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | Imdb _$ImdbFromJson(Map json) => Imdb( 10 | rating: (json['rating'] as num?)?.toDouble() ?? 0, 11 | votes: json['votes'] as int? ?? 0, 12 | lastRating: (json['lastRating'] as num?)?.toDouble() ?? 0, 13 | lastComment: json['lastComment'] as String? ?? '', 14 | thumbUrl: json['thumbUrl'] as String? ?? '', 15 | ); 16 | 17 | Map _$ImdbToJson(Imdb instance) => { 18 | 'rating': instance.rating, 19 | 'votes': instance.votes, 20 | 'lastRating': instance.lastRating, 21 | 'lastComment': instance.lastComment, 22 | 'thumbUrl': instance.thumbUrl, 23 | }; 24 | -------------------------------------------------------------------------------- /lib/data/entities/realdebrid.dart: -------------------------------------------------------------------------------- 1 | import 'package:json_annotation/json_annotation.dart'; 2 | 3 | part 'realdebrid.g.dart'; 4 | 5 | @JsonSerializable() 6 | class RealDebrid { 7 | String id; 8 | String filename; 9 | int filesize; 10 | String download; 11 | List info; 12 | 13 | RealDebrid( 14 | {this.id = '', 15 | this.filename = '', 16 | this.filesize = 0, 17 | this.download = '', 18 | this.info = const []}); 19 | 20 | factory RealDebrid.fromJson(Map json) => 21 | _$RealDebridFromJson(json); 22 | } 23 | -------------------------------------------------------------------------------- /lib/data/entities/realdebrid.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'realdebrid.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | RealDebrid _$RealDebridFromJson(Map json) => RealDebrid( 10 | id: json['id'] as String? ?? '', 11 | filename: json['filename'] as String? ?? '', 12 | filesize: json['filesize'] as int? ?? 0, 13 | download: json['download'] as String? ?? '', 14 | info: 15 | (json['info'] as List?)?.map((e) => e as String).toList() ?? 16 | const [], 17 | ); 18 | 19 | Map _$RealDebridToJson(RealDebrid instance) => 20 | { 21 | 'id': instance.id, 22 | 'filename': instance.filename, 23 | 'filesize': instance.filesize, 24 | 'download': instance.download, 25 | 'info': instance.info, 26 | }; 27 | -------------------------------------------------------------------------------- /lib/data/entities/scrape.dart: -------------------------------------------------------------------------------- 1 | import 'package:json_annotation/json_annotation.dart'; 2 | part 'scrape.g.dart'; 3 | 4 | @JsonSerializable() 5 | class Scrape { 6 | String url; 7 | String magnet; 8 | @JsonKey(name: 'release_title') 9 | String title; 10 | @JsonKey(name: 'scraper') 11 | @JsonKey(defaultValue: 'unknown') 12 | String scraper; 13 | int size; 14 | String quality; 15 | List info; 16 | List links; 17 | 18 | Scrape( 19 | {this.url = '', 20 | this.magnet = '', 21 | this.title = '', 22 | this.scraper = '', 23 | this.size = 0, 24 | this.quality = '', 25 | this.links = const [], 26 | this.info = const []}); 27 | 28 | factory Scrape.fromJson(Map json) => _$ScrapeFromJson(json); 29 | } 30 | -------------------------------------------------------------------------------- /lib/data/entities/scrape.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'scrape.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | Scrape _$ScrapeFromJson(Map json) => Scrape( 10 | url: json['url'] as String? ?? '', 11 | magnet: json['magnet'] as String? ?? '', 12 | title: json['release_title'] as String? ?? '', 13 | scraper: json['scraper'] as String? ?? '', 14 | size: json['size'] as int? ?? 0, 15 | quality: json['quality'] as String? ?? '', 16 | links: json['links'] as List? ?? const [], 17 | info: 18 | (json['info'] as List?)?.map((e) => e as String).toList() ?? 19 | const [], 20 | ); 21 | 22 | Map _$ScrapeToJson(Scrape instance) => { 23 | 'url': instance.url, 24 | 'magnet': instance.magnet, 25 | 'release_title': instance.title, 26 | 'scraper': instance.scraper, 27 | 'size': instance.size, 28 | 'quality': instance.quality, 29 | 'info': instance.info, 30 | 'links': instance.links, 31 | }; 32 | -------------------------------------------------------------------------------- /lib/data/entities/tmdb.dart: -------------------------------------------------------------------------------- 1 | import 'package:json_annotation/json_annotation.dart'; 2 | 3 | part 'tmdb.g.dart'; 4 | 5 | @JsonSerializable(explicitToJson: true) 6 | class TmdbCast { 7 | final String name; 8 | @JsonKey(name: 'profile_path') 9 | final String profilePath; 10 | final String character; 11 | String get profileBig => 'https://image.tmdb.org/t/p/w780$profilePath'; 12 | String get profileSmall => 'https://image.tmdb.org/t/p/w342$profilePath'; 13 | TmdbCast({this.name = '', this.profilePath = '', this.character = ''}); 14 | factory TmdbCast.fromJson(Map json) => 15 | _$TmdbCastFromJson(json); 16 | 17 | Map toJson() => _$TmdbCastToJson(this); 18 | } 19 | 20 | @JsonSerializable(explicitToJson: true) 21 | class TmdbCredits { 22 | final List cast; 23 | const TmdbCredits({this.cast = const []}); 24 | factory TmdbCredits.fromJson(Map json) => 25 | _$TmdbCreditsFromJson(json); 26 | Map toJson() => _$TmdbCreditsToJson(this); 27 | } 28 | 29 | @JsonSerializable(explicitToJson: true) 30 | class TmdbProductionCompany { 31 | @JsonKey(name: 'logo_path') 32 | String logoPath; 33 | String name; 34 | 35 | TmdbProductionCompany({this.name = '', this.logoPath = ''}); 36 | factory TmdbProductionCompany.fromJson(Map json) => 37 | _$TmdbProductionCompanyFromJson(json); 38 | Map toJson() => _$TmdbProductionCompanyToJson(this); 39 | } 40 | 41 | @JsonSerializable(explicitToJson: true) 42 | class Tmdb { 43 | int id; 44 | @JsonKey(name: 'poster_path') 45 | String posterPath; 46 | @JsonKey(name: 'backdrop_path') 47 | String backdropPath; 48 | 49 | @JsonKey(name: 'logo_path') 50 | String logoPath; 51 | 52 | @JsonKey(name: 'original_title') 53 | String originalTitle; 54 | 55 | @JsonKey(name: 'stillPath') 56 | String stillPath; 57 | 58 | @JsonKey(name: 'episode_number') 59 | int episodeNumber; 60 | 61 | @JsonKey(name: 'vote_average') 62 | double voteAverage; 63 | 64 | @JsonKey(name: 'vote_count') 65 | int voteCount; 66 | 67 | @JsonKey(name: 'credits') 68 | TmdbCredits credits; 69 | 70 | List episodes; 71 | 72 | @JsonKey(name: 'production_companies') 73 | List productionCompanies; 74 | 75 | double get roundedRating => double.parse(voteAverage.toStringAsFixed(1)); 76 | String get smallPath => 'https://image.tmdb.org/t/p/w342'; 77 | String get posterBig => 'https://image.tmdb.org/t/p/w780$posterPath'; 78 | String get posterSmall => 'https://image.tmdb.org/t/p/w342$posterPath'; 79 | String get logoBig => 'https://image.tmdb.org/t/p/w780$logoPath'; 80 | String get logoSmall => 'https://image.tmdb.org/t/p/w342$logoPath'; 81 | String get stillBig => 'https://image.tmdb.org/t/p/w300$stillPath'; 82 | String get stillSmall => 'https://image.tmdb.org/t/p/w185$stillPath'; 83 | String get backdropBig => 'https://image.tmdb.org/t/p/w1280$backdropPath'; 84 | String get backdropSmall => 'https://image.tmdb.org/t/p/w780$backdropPath'; 85 | 86 | Tmdb( 87 | {this.posterPath = '', 88 | this.id = 0, 89 | this.backdropPath = '', 90 | this.logoPath = '', 91 | this.stillPath = '', 92 | this.episodeNumber = 0, 93 | this.voteCount = 0, 94 | this.voteAverage = 0.0, 95 | this.originalTitle = '', 96 | this.productionCompanies = const [], 97 | this.episodes = const [], 98 | this.credits = const TmdbCredits(cast: [])}); 99 | 100 | factory Tmdb.fromJson(Map json) => _$TmdbFromJson(json); 101 | Map toJson() => _$TmdbToJson(this); 102 | } 103 | -------------------------------------------------------------------------------- /lib/data/entities/tmdb.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'tmdb.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | TmdbCast _$TmdbCastFromJson(Map json) => TmdbCast( 10 | name: json['name'] as String? ?? '', 11 | profilePath: json['profile_path'] as String? ?? '', 12 | character: json['character'] as String? ?? '', 13 | ); 14 | 15 | Map _$TmdbCastToJson(TmdbCast instance) => { 16 | 'name': instance.name, 17 | 'profile_path': instance.profilePath, 18 | 'character': instance.character, 19 | }; 20 | 21 | TmdbCredits _$TmdbCreditsFromJson(Map json) => TmdbCredits( 22 | cast: (json['cast'] as List?) 23 | ?.map((e) => TmdbCast.fromJson(e as Map)) 24 | .toList() ?? 25 | const [], 26 | ); 27 | 28 | Map _$TmdbCreditsToJson(TmdbCredits instance) => 29 | { 30 | 'cast': instance.cast.map((e) => e.toJson()).toList(), 31 | }; 32 | 33 | TmdbProductionCompany _$TmdbProductionCompanyFromJson( 34 | Map json) => 35 | TmdbProductionCompany( 36 | name: json['name'] as String? ?? '', 37 | logoPath: json['logo_path'] as String? ?? '', 38 | ); 39 | 40 | Map _$TmdbProductionCompanyToJson( 41 | TmdbProductionCompany instance) => 42 | { 43 | 'logo_path': instance.logoPath, 44 | 'name': instance.name, 45 | }; 46 | 47 | Tmdb _$TmdbFromJson(Map json) => Tmdb( 48 | posterPath: json['poster_path'] as String? ?? '', 49 | logoPath: json['logo_path'] as String? ?? '', 50 | id: json['id'] as int? ?? 0, 51 | backdropPath: json['backdrop_path'] as String? ?? '', 52 | stillPath: json['stillPath'] as String? ?? '', 53 | episodeNumber: json['episode_number'] as int? ?? 0, 54 | voteCount: json['vote_count'] as int? ?? 0, 55 | voteAverage: (json['vote_average'] as num?)?.toDouble() ?? 0.0, 56 | originalTitle: json['original_title'] as String? ?? '', 57 | productionCompanies: (json['production_companies'] as List?) 58 | ?.map((e) => 59 | TmdbProductionCompany.fromJson(e as Map)) 60 | .toList() ?? 61 | const [], 62 | episodes: (json['episodes'] as List?) 63 | ?.map((e) => Tmdb.fromJson(e as Map)) 64 | .toList() ?? 65 | const [], 66 | credits: json['credits'] == null 67 | ? const TmdbCredits(cast: []) 68 | : TmdbCredits.fromJson(json['credits'] as Map), 69 | ); 70 | 71 | Map _$TmdbToJson(Tmdb instance) => { 72 | 'id': instance.id, 73 | 'poster_path': instance.posterPath, 74 | 'logo_path': instance.logoPath, 75 | 'backdrop_path': instance.backdropPath, 76 | 'original_title': instance.originalTitle, 77 | 'stillPath': instance.stillPath, 78 | 'episode_number': instance.episodeNumber, 79 | 'vote_average': instance.voteAverage, 80 | 'vote_count': instance.voteCount, 81 | 'credits': instance.credits.toJson(), 82 | 'episodes': instance.episodes.map((e) => e.toJson()).toList(), 83 | 'production_companies': 84 | instance.productionCompanies.map((e) => e.toJson()).toList(), 85 | }; 86 | -------------------------------------------------------------------------------- /lib/data/entities/trakt.dart: -------------------------------------------------------------------------------- 1 | import 'package:json_annotation/json_annotation.dart'; 2 | import 'package:odin/data/entities/tmdb.dart'; 3 | 4 | import 'imdb.dart'; 5 | 6 | part 'trakt.g.dart'; 7 | 8 | @JsonSerializable(explicitToJson: true) 9 | class TraktIds { 10 | final int trakt; 11 | final String slug; 12 | final String imdb; 13 | final int tmdb; 14 | final int tvdb; 15 | const TraktIds( 16 | {this.trakt = 0, 17 | this.slug = '', 18 | this.imdb = '', 19 | this.tmdb = 0, 20 | this.tvdb = 0}); 21 | factory TraktIds.fromJson(Map json) => 22 | _$TraktIdsFromJson(json); 23 | 24 | Map toJson() => _$TraktIdsToJson(this); 25 | } 26 | 27 | @JsonSerializable(explicitToJson: true) 28 | class TraktEpisode { 29 | String title; 30 | int season; 31 | int number; 32 | bool watched; 33 | TraktEpisode( 34 | {this.title = '', 35 | this.season = 0, 36 | this.number = 0, 37 | this.watched = false}); 38 | 39 | TraktEpisode setWatched(bool w) { 40 | watched = w; 41 | return this; 42 | } 43 | 44 | factory TraktEpisode.fromJson(Map json) => 45 | _$TraktEpisodeFromJson(json); 46 | Map toJson() => _$TraktEpisodeToJson(this); 47 | } 48 | 49 | @JsonSerializable(explicitToJson: true) 50 | class Trakt { 51 | String type; 52 | TraktIds ids; 53 | String title; 54 | int year; 55 | Trakt? show; 56 | String language; 57 | double rating; 58 | int votes; 59 | List genres; 60 | String overview; 61 | int runtime; 62 | String country; 63 | String trailer; 64 | bool watched; 65 | TraktEpisode? episode; 66 | @JsonKey(name: 'seasons') 67 | List seasons; 68 | List episodes; 69 | @JsonKey(name: 'tmdb') 70 | Tmdb? tmdb; 71 | Imdb? imdb; 72 | 73 | DateTime released; 74 | String tagline; 75 | 76 | @JsonKey(name: 'first_aired') 77 | DateTime firstAired; 78 | String network; 79 | String status; 80 | 81 | int number; 82 | @JsonKey(name: 'episode_count') 83 | int episodeCount; 84 | @JsonKey(name: 'aired_episodes') 85 | int airedEpisodes; 86 | int season; 87 | 88 | bool get isMovie => type == 'movie'; 89 | bool get isShow => type == 'show'; 90 | bool get isSeason => type == 'season'; 91 | bool get isEpisode => type == 'episode'; 92 | 93 | Trakt setType(String t) { 94 | type = t; 95 | return this; 96 | } 97 | 98 | Trakt setWatched(bool w) { 99 | watched = w; 100 | return this; 101 | } 102 | 103 | double get roundedRating => double.parse(rating.toStringAsFixed(1)); 104 | 105 | Trakt( 106 | {this.ids = const TraktIds(), 107 | this.type = '', 108 | this.watched = false, 109 | this.title = '', 110 | this.year = 0, 111 | this.rating = 0.0, 112 | this.votes = 0, 113 | this.genres = const [], 114 | this.country = '', 115 | this.trailer = '', 116 | this.network = '', 117 | this.status = '', 118 | this.language = '', 119 | this.overview = 'No overview', 120 | this.number = 0, 121 | this.season = 0, 122 | this.runtime = 0, 123 | this.episodeCount = 0, 124 | this.airedEpisodes = 0, 125 | this.seasons = const [], 126 | this.episodes = const [], 127 | this.show, 128 | this.tagline = '', 129 | DateTime? released, 130 | DateTime? firstAired}) 131 | : released = released ?? DateTime.now(), 132 | firstAired = firstAired ?? DateTime.now(); 133 | 134 | factory Trakt.fromJson(Map json) => _$TraktFromJson(json); 135 | Map toJson() => _$TraktToJson(this); 136 | } 137 | -------------------------------------------------------------------------------- /lib/data/entities/trakt.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'trakt.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | TraktIds _$TraktIdsFromJson(Map json) => TraktIds( 10 | trakt: json['trakt'] as int? ?? 0, 11 | slug: json['slug'] as String? ?? '', 12 | imdb: json['imdb'] as String? ?? '', 13 | tmdb: json['tmdb'] as int? ?? 0, 14 | tvdb: json['tvdb'] as int? ?? 0, 15 | ); 16 | 17 | Map _$TraktIdsToJson(TraktIds instance) => { 18 | 'trakt': instance.trakt, 19 | 'slug': instance.slug, 20 | 'imdb': instance.imdb, 21 | 'tmdb': instance.tmdb, 22 | 'tvdb': instance.tvdb, 23 | }; 24 | 25 | TraktEpisode _$TraktEpisodeFromJson(Map json) => TraktEpisode( 26 | title: json['title'] as String? ?? '', 27 | season: json['season'] as int? ?? 0, 28 | number: json['number'] as int? ?? 0, 29 | watched: json['watched'] as bool? ?? false, 30 | ); 31 | 32 | Map _$TraktEpisodeToJson(TraktEpisode instance) => 33 | { 34 | 'title': instance.title, 35 | 'season': instance.season, 36 | 'number': instance.number, 37 | 'watched': instance.watched, 38 | }; 39 | 40 | Trakt _$TraktFromJson(Map json) => Trakt( 41 | ids: json['ids'] == null 42 | ? const TraktIds() 43 | : TraktIds.fromJson(json['ids'] as Map), 44 | type: json['type'] as String? ?? '', 45 | watched: json['watched'] as bool? ?? false, 46 | title: json['title'] as String? ?? '', 47 | year: json['year'] as int? ?? 0, 48 | rating: (json['rating'] as num?)?.toDouble() ?? 0.0, 49 | votes: json['votes'] as int? ?? 0, 50 | genres: (json['genres'] as List?) 51 | ?.map((e) => e as String) 52 | .toList() ?? 53 | const [], 54 | country: json['country'] as String? ?? '', 55 | trailer: json['trailer'] as String? ?? '', 56 | network: json['network'] as String? ?? '', 57 | status: json['status'] as String? ?? '', 58 | language: json['language'] as String? ?? '', 59 | overview: json['overview'] as String? ?? 'No overview', 60 | number: json['number'] as int? ?? 0, 61 | season: json['season'] as int? ?? 0, 62 | runtime: json['runtime'] as int? ?? 0, 63 | episodeCount: json['episode_count'] as int? ?? 0, 64 | airedEpisodes: json['aired_episodes'] as int? ?? 0, 65 | seasons: (json['seasons'] as List?) 66 | ?.map((e) => Trakt.fromJson(e as Map)) 67 | .toList() ?? 68 | const [], 69 | episodes: (json['episodes'] as List?) 70 | ?.map((e) => Trakt.fromJson(e as Map)) 71 | .toList() ?? 72 | const [], 73 | show: json['show'] == null 74 | ? null 75 | : Trakt.fromJson(json['show'] as Map), 76 | tagline: json['tagline'] as String? ?? '', 77 | released: json['released'] == null 78 | ? null 79 | : DateTime.parse(json['released'] as String), 80 | firstAired: json['first_aired'] == null 81 | ? null 82 | : DateTime.parse(json['first_aired'] as String), 83 | ) 84 | ..episode = json['episode'] == null 85 | ? null 86 | : TraktEpisode.fromJson(json['episode'] as Map) 87 | ..tmdb = json['tmdb'] == null 88 | ? null 89 | : Tmdb.fromJson(json['tmdb'] as Map) 90 | ..imdb = json['imdb'] == null 91 | ? null 92 | : Imdb.fromJson(json['imdb'] as Map); 93 | 94 | Map _$TraktToJson(Trakt instance) => { 95 | 'type': instance.type, 96 | 'ids': instance.ids.toJson(), 97 | 'title': instance.title, 98 | 'year': instance.year, 99 | 'show': instance.show?.toJson(), 100 | 'language': instance.language, 101 | 'rating': instance.rating, 102 | 'votes': instance.votes, 103 | 'genres': instance.genres, 104 | 'overview': instance.overview, 105 | 'runtime': instance.runtime, 106 | 'country': instance.country, 107 | 'trailer': instance.trailer, 108 | 'watched': instance.watched, 109 | 'episode': instance.episode?.toJson(), 110 | 'seasons': instance.seasons.map((e) => e.toJson()).toList(), 111 | 'episodes': instance.episodes.map((e) => e.toJson()).toList(), 112 | 'tmdb': instance.tmdb?.toJson(), 113 | 'imdb': instance.imdb?.toJson(), 114 | 'released': instance.released.toIso8601String(), 115 | 'tagline': instance.tagline, 116 | 'first_aired': instance.firstAired.toIso8601String(), 117 | 'network': instance.network, 118 | 'status': instance.status, 119 | 'number': instance.number, 120 | 'episode_count': instance.episodeCount, 121 | 'aired_episodes': instance.airedEpisodes, 122 | 'season': instance.season, 123 | }; 124 | -------------------------------------------------------------------------------- /lib/data/entities/user.dart: -------------------------------------------------------------------------------- 1 | class User { 2 | String username; 3 | String name; 4 | String avatar; 5 | bool vip; 6 | String id; 7 | DateTime? expiration; 8 | 9 | User( 10 | {this.username = '', 11 | this.name = '', 12 | this.id = '', 13 | this.vip = false, 14 | this.avatar = '', 15 | this.expiration}); 16 | 17 | Map toJSon() => { 18 | 'name': name, 19 | 'username': username, 20 | 'vip': vip, 21 | 'id': id, 22 | 'avatar': avatar, 23 | }; 24 | 25 | factory User.fromTrakt(dynamic json) { 26 | return User( 27 | id: json['user']['ids']['uuid'] ?? '', 28 | username: json['user']['username'] ?? '', 29 | name: json['user']['name'] ?? '', 30 | vip: json['user']['vip'] ?? false, 31 | avatar: json['user']['images']['avatar']['full'] ?? ''); 32 | } 33 | factory User.fromRealDebrid(dynamic json) { 34 | return User( 35 | username: json['username'] ?? '', 36 | avatar: json['avatar'] ?? '', 37 | vip: json['type'] == 'premium', 38 | expiration: DateTime.parse(json['expiration'])); 39 | } 40 | factory User.fromJson(dynamic json) { 41 | return User( 42 | name: json['name'], 43 | id: json['id'], 44 | username: json['username'], 45 | avatar: json['avatar'], 46 | vip: json['vip']); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /lib/data/models/auth_model.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:convert'; 3 | 4 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 5 | import 'package:odin/data/services/api.dart'; 6 | import 'package:odin/data/services/db.dart'; 7 | import 'package:odin/helpers.dart'; 8 | import 'package:uuid/v4.dart'; 9 | import 'package:web_socket_channel/web_socket_channel.dart'; 10 | 11 | enum AuthState { ok, login, error, multiple, add, select } 12 | 13 | class ValidationStatus { 14 | final int status; 15 | final dynamic user; 16 | ValidationStatus(this.status, this.user); 17 | } 18 | 19 | class AuthObject { 20 | final String device; 21 | final String url; 22 | final dynamic user; 23 | 24 | AuthObject(this.device, this.url, this.user); 25 | } 26 | 27 | class AuthModel extends StateNotifier with BaseHelper { 28 | DB db; 29 | ValidationService validation; 30 | final Ref ref; 31 | String code = "..."; 32 | AuthObject? me; 33 | AuthModel(this.ref, this.db, this.validation) : super(AuthState.error); 34 | 35 | Future check({bool showSelect = false}) async { 36 | final allcreds = await getAllCreds(); 37 | if (allcreds.isEmpty) { 38 | login(); 39 | return; 40 | } 41 | if (allcreds.length > 1 || showSelect) { 42 | state = AuthState.multiple; 43 | return; 44 | } 45 | 46 | if (await verify(allcreds[0]) == false) { 47 | return; 48 | } 49 | me = allcreds[0]; 50 | state = AuthState.ok; 51 | } 52 | 53 | Future newUser() async { 54 | login(); 55 | } 56 | 57 | switchUser() { 58 | state = AuthState.multiple; 59 | } 60 | 61 | Future selectUser(AuthObject creds) async { 62 | logInfo("Selecting"); 63 | if (!await verify(creds)) { 64 | return; 65 | } 66 | logInfo("Should be here"); 67 | me = creds; 68 | state = AuthState.ok; 69 | } 70 | 71 | Future verify(AuthObject creds) async { 72 | final v = await validate(creds.url, creds.device); 73 | if (v.status == 0) { 74 | state = AuthState.error; 75 | return false; 76 | } 77 | if (v.status == 404) { 78 | await clear(creds.device); 79 | login(); 80 | return false; 81 | } 82 | return true; 83 | } 84 | 85 | Future delete(String id) async { 86 | logWarning(id); 87 | await db.users?.delete(id); 88 | check(showSelect: true); 89 | } 90 | 91 | Future clear(String device) async { 92 | await db.users?.delete(device); 93 | } 94 | 95 | Future validate(String url, String id) async { 96 | return (await validation.check(url, id)).match( 97 | (l) => ValidationStatus(0, null), 98 | (r) => ValidationStatus(r["status"], r["user"])); 99 | } 100 | 101 | Future getCredentials() async { 102 | final allcreds = await getAllCreds(); 103 | final me = allcreds[0]; 104 | 105 | return {"url": me.url, "device": me.device}; 106 | } 107 | 108 | Future> getAllCreds() async { 109 | final List? allcreds = await db.users?.getAllKeys(); 110 | if (allcreds == null || allcreds.isEmpty) { 111 | return []; 112 | } 113 | 114 | final creds = []; 115 | await Future.forEach(allcreds, (String c) async { 116 | final data = await db.users?.get(c); 117 | creds.add(AuthObject(c, data["apiUrl"], data["user"])); 118 | }); 119 | 120 | return creds; 121 | } 122 | 123 | Future login() async { 124 | state = AuthState.login; 125 | ref.read(urlProvider.notifier).state = ""; 126 | final code = ref.refresh(codeProvider); 127 | logInfo(code); 128 | bool result = false; 129 | 130 | final wsUrl = Uri.parse('wss://ntfy.sh/odinmovieshow-$code/ws'); 131 | final channel = WebSocketChannel.connect(wsUrl); 132 | 133 | await channel.ready; 134 | var url = ""; 135 | var id = ""; 136 | final listen = channel.stream.listen((event) { 137 | try { 138 | var data = json.decode(event as String); 139 | if (data["message"] != null) { 140 | var m = json.decode(data["message"]); 141 | url = m["url"].replaceAll('"', ""); 142 | id = m["deviceId"].replaceAll('"', ""); 143 | result = true; 144 | } 145 | } catch (_) { 146 | // print(e); 147 | } 148 | }); 149 | 150 | while (!result) { 151 | await Future.delayed(const Duration(seconds: 1)); 152 | } 153 | 154 | logInfo(url); 155 | logInfo(id); 156 | 157 | listen.cancel(); 158 | 159 | ref.read(urlProvider.notifier).state = url; 160 | await Future.delayed(const Duration(seconds: 5)); 161 | 162 | final v = await validate(url, id); 163 | 164 | if (v.status > 0 && v.status < 300) { 165 | await db.users?.put(id, {"apiUrl": url, "user": v.user}); 166 | logOk("Auth: Successful!"); 167 | me = AuthObject(id, url, v.user); 168 | state = AuthState.ok; 169 | } else { 170 | if (v.status == 0) { 171 | ref.read(errorProvider.notifier).state = 172 | "Network error: Cannot reach URL"; 173 | } 174 | if (v.status > 399) { 175 | ref.read(errorProvider.notifier).state = 176 | "Authorization error: Something is wrong"; 177 | } 178 | state = AuthState.login; 179 | return await login(); 180 | } 181 | } 182 | } 183 | 184 | final urlProvider = StateProvider((ref) { 185 | return ""; 186 | }); 187 | 188 | final usersProvider = FutureProvider>((ref) { 189 | return ref.watch(authProvider.notifier).getAllCreds(); 190 | }); 191 | 192 | final errorProvider = StateProvider((ref) { 193 | return ""; 194 | }); 195 | 196 | final authProvider = StateNotifierProvider((ref) => AuthModel( 197 | ref, ref.watch(dbProvider.notifier), ref.watch(validationProvider))); 198 | 199 | final codeProvider = StateProvider((ref) { 200 | String c = const UuidV4().generate().split("-").first.toString(); 201 | return c; 202 | }); 203 | -------------------------------------------------------------------------------- /lib/data/models/item_model.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 2 | import 'package:helpers/helpers/print.dart'; 3 | import 'package:odin/data/services/trakt_service.dart'; 4 | 5 | import 'package:odin/data/entities/trakt.dart'; 6 | import 'package:odin/helpers.dart'; 7 | 8 | var genres = [ 9 | {"name": "Action", "slug": "action"}, 10 | {"name": "Adventure", "slug": "adventure"}, 11 | {"name": "Animation", "slug": "animation"}, 12 | {"name": "Anime", "slug": "anime"}, 13 | {"name": "Comedy", "slug": "comedy"}, 14 | {"name": "Crime", "slug": "crime"}, 15 | {"name": "Documentary", "slug": "documentary"}, 16 | {"name": "Drama", "slug": "drama"}, 17 | {"name": "Family", "slug": "family"}, 18 | {"name": "Fantasy", "slug": "fantasy"}, 19 | {"name": "History", "slug": "history"}, 20 | {"name": "Holiday", "slug": "holiday"}, 21 | {"name": "Horror", "slug": "horror"}, 22 | {"name": "Music", "slug": "music"}, 23 | {"name": "Musical", "slug": "musical"}, 24 | {"name": "Mystery", "slug": "mystery"}, 25 | {"name": "None", "slug": "none"}, 26 | {"name": "Romance", "slug": "romance"}, 27 | {"name": "Science Fiction", "slug": "science-fiction"}, 28 | {"name": "Short", "slug": "short"}, 29 | {"name": "Sporting Event", "slug": "sporting-event"}, 30 | {"name": "Superhero", "slug": "superhero"}, 31 | {"name": "Suspense", "slug": "suspense"}, 32 | {"name": "Thriller", "slug": "thriller"}, 33 | {"name": "War", "slug": "war"}, 34 | {"name": "Western", "slug": "western"} 35 | ]; 36 | 37 | class ListData { 38 | int page = 1; 39 | List items = []; 40 | } 41 | 42 | class SectionItem { 43 | String title; 44 | String url; 45 | String? type; 46 | bool? isGenre; 47 | bool big; 48 | bool filterWatched; 49 | bool isTodayTomorrowEpisodes; 50 | List items = []; 51 | bool paginate; 52 | 53 | SectionItem({ 54 | this.title = '', 55 | this.url = '', 56 | this.type, 57 | this.isGenre, 58 | this.big = false, 59 | this.filterWatched = false, 60 | this.isTodayTomorrowEpisodes = false, 61 | this.items = const [], 62 | this.paginate = true, 63 | }); 64 | } 65 | 66 | class ListSetting { 67 | ListSetting(this.url, this.page); 68 | String url = ''; 69 | int page = 1; 70 | } 71 | 72 | class ItemsProvider extends StateNotifier> { 73 | final Ref ref; 74 | final ItemsProviderData data; 75 | final TraktService traktService; 76 | final dynamic appRefreshProvider; 77 | int page = 0; 78 | // final page; 79 | // List items = []; 80 | ItemsProvider(this.ref, this.traktService, this.appRefreshProvider, this.data) 81 | : super([]) { 82 | page = 1; 83 | load(); 84 | } 85 | 86 | String getUrl(String url) { 87 | var append = '?'; 88 | if (url.contains('?')) { 89 | append = '&'; 90 | } 91 | return '$url${append}limit=30&page=$page'; 92 | } 93 | 94 | void load() async { 95 | Future.delayed(const Duration(milliseconds: 10), () { 96 | appRefreshProvider.state = true; 97 | }); 98 | List list = 99 | await ref.watch(traktProvider).getItems(getUrl(data.url)); 100 | 101 | if (data.filterWatched) { 102 | list = list.where((i) => i.watched == false).toList(); 103 | } 104 | 105 | state = [...state, ...list]; 106 | Future.delayed(const Duration(milliseconds: 10), () { 107 | appRefreshProvider.state = false; 108 | }); 109 | } 110 | 111 | void next() async { 112 | page++; 113 | load(); 114 | } 115 | } 116 | 117 | class ItemsProviderData { 118 | final String url; 119 | final bool filterWatched; 120 | ItemsProviderData(this.url, this.filterWatched); 121 | } 122 | 123 | final itemsProvider = AutoDisposeStateNotifierProviderFamily, ItemsProviderData>((ref, data) { 125 | return ItemsProvider(ref, ref.watch(traktProvider), 126 | ref.read(appRefreshProvider.notifier), data); 127 | }); 128 | 129 | final appRefreshProvider = StateProvider.autoDispose((ref) => false); 130 | 131 | final genreProvider = 132 | StateProvider.autoDispose.family((ref, type) => 0); 133 | -------------------------------------------------------------------------------- /lib/data/models/settings_model.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 2 | import 'package:odin/data/entities/config.dart'; 3 | import 'package:odin/data/models/auth_model.dart'; 4 | import 'package:odin/helpers.dart'; 5 | 6 | import '../services/db.dart'; 7 | 8 | List> players = [ 9 | {'title': 'Just', 'id': 'com.brouken.player'}, 10 | {'title': 'MX Player', 'id': 'com.mxtech.videoplayer.pro'}, 11 | {'title': 'Nova', 'id': 'org.courville.nova'}, 12 | {'title': 'Kodi', 'id': 'org.xbmc.kodi'}, 13 | {'title': 'VLC', 'id': 'org.videolan.vlc'}, 14 | ]; 15 | 16 | class SettingsModel with BaseHelper { 17 | final Ref ref; 18 | final DB db; 19 | final AuthModel auth; 20 | 21 | SettingsModel(this.ref, this.db, this.auth) { 22 | init(); 23 | } 24 | 25 | Config config = Config(); 26 | 27 | Map get player => 28 | players.firstWhere((element) => element['title'] == config.player); 29 | 30 | void init() async { 31 | final dynamic mydb = await db.users?.get(auth.me!.device); 32 | final saved = mydb["settings"]; 33 | config = Config( 34 | player: saved?['player'] ?? "Just", 35 | scrobble: saved?['scrobble'] ?? true); 36 | } 37 | 38 | void save() async { 39 | final dynamic mydb = await db.users?.get(auth.me!.device); 40 | mydb["settings"] = {'player': config.player, 'scrobble': config.scrobble}; 41 | await db.users?.put(auth.me!.device, mydb); 42 | } 43 | } 44 | 45 | final settingsProvider = Provider((ref) => SettingsModel( 46 | ref, ref.watch(dbProvider.notifier), ref.watch(authProvider.notifier))); 47 | -------------------------------------------------------------------------------- /lib/data/services/api.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:device_info_plus/device_info_plus.dart'; 4 | import 'package:dio/dio.dart'; 5 | import 'package:dio/io.dart'; 6 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 7 | import 'package:fpdart/fpdart.dart'; 8 | import 'package:odin/data/models/auth_model.dart'; 9 | import 'package:odin/helpers.dart'; 10 | 11 | class ApiService with BaseHelper { 12 | final Ref ref; 13 | final Dio dio = Dio(); 14 | AuthModel auth; 15 | ApiService(this.ref, this.auth) { 16 | // ref.watch(authProvider); 17 | onInit(); 18 | } 19 | 20 | void onInit() async { 21 | (dio.httpClientAdapter as IOHttpClientAdapter).createHttpClient = () { 22 | final client = HttpClient(); 23 | client.badCertificateCallback = 24 | (X509Certificate cert, String host, int port) { 25 | return true; 26 | }; 27 | return client; 28 | }; 29 | dio.interceptors 30 | .add(InterceptorsWrapper(onRequest: (options, handler) async { 31 | var apiUrl = auth.me?.url; 32 | var device = auth.me?.device; 33 | if (apiUrl != null && device != null) { 34 | options.baseUrl = '$apiUrl'; 35 | options.headers.addAll({'Device': device}); 36 | } 37 | // options.connectTimeout = const Duration(seconds: 2); 38 | // options.receiveTimeout = const Duration(minutes: 15); 39 | // options.sendTimeout = const Duration(minutes: 15); 40 | options.followRedirects = true; 41 | 42 | return handler.next(options); 43 | })); 44 | } 45 | 46 | Future> get(String url, 47 | {Duration timeout = const Duration(minutes: 1)}) async { 48 | try { 49 | dio.options.connectTimeout = timeout; 50 | final response = await dio.get(url); 51 | return Right(response.data); 52 | } on DioException catch (e) { 53 | logWarning(url); 54 | logError(e, e.stackTrace); 55 | return Left(e); 56 | } 57 | } 58 | 59 | Future> post(String url, dynamic data) async { 60 | try { 61 | final response = await dio.post(url, data: data); 62 | return Right(response.data); 63 | } on DioException catch (e) { 64 | logWarning(url); 65 | logError(e, e.stackTrace); 66 | return Left(e); 67 | } 68 | } 69 | } 70 | 71 | final apiProvider = 72 | Provider((ref) => ApiService(ref, ref.watch(authProvider.notifier))); 73 | 74 | final healthProvider = StreamProvider.autoDispose((ref) async* { 75 | final api = ref.watch(apiProvider); 76 | var healthy = true; 77 | 78 | await for (final _ in Stream.periodic(const Duration(seconds: 5))) { 79 | try { 80 | healthy = (await api.get('/-/health?ping=true', 81 | timeout: const Duration(seconds: 2))) 82 | .fold((l) => false, (r) => true); 83 | yield healthy; 84 | } catch (_) { 85 | yield false; 86 | } 87 | } 88 | }); 89 | 90 | final statusProvider = FutureProvider((ref) async { 91 | final api = ref.watch(apiProvider); 92 | return (await api.get("/-/health")).match((l) => {}, (r) => r); 93 | }); 94 | 95 | class ValidationService with BaseHelper { 96 | Future getDeviceInfo() async { 97 | try { 98 | DeviceInfoPlugin deviceInfo = DeviceInfoPlugin(); 99 | AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo; 100 | return "${androidInfo.manufacturer} ${androidInfo.model}"; 101 | } catch (_) {} 102 | return "Unknown"; 103 | } 104 | 105 | Future> check(String url, String device) async { 106 | final dio2 = Dio(); 107 | (dio2.httpClientAdapter as IOHttpClientAdapter).createHttpClient = () { 108 | final client = HttpClient(); 109 | client.badCertificateCallback = 110 | (X509Certificate cert, String host, int port) { 111 | return true; 112 | }; 113 | return client; 114 | }; 115 | dio2.interceptors 116 | .add(InterceptorsWrapper(onError: (DioException e, handler) async { 117 | logWarning(e); 118 | return handler.next(e); 119 | }, onRequest: (options, handler) async { 120 | options.connectTimeout = const Duration(seconds: 2); 121 | options.receiveTimeout = const Duration(seconds: 2); 122 | options.sendTimeout = const Duration(seconds: 2); 123 | options.followRedirects = true; 124 | 125 | return handler.next(options); 126 | })); 127 | 128 | final name = await getDeviceInfo(); 129 | 130 | try { 131 | final resp = await dio2.get("$url/-/device/verify/$device/$name"); 132 | return Right({"status": resp.statusCode!, "user": resp.data}); 133 | } on DioException catch (e) { 134 | logWarning(e); 135 | return const Left(true); 136 | } catch (e) { 137 | logWarning(e); 138 | return const Left(true); 139 | } 140 | } 141 | } 142 | 143 | final validationProvider = Provider((ref) => ValidationService()); 144 | -------------------------------------------------------------------------------- /lib/data/services/db.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 2 | import 'package:hive_ce_flutter/hive_flutter.dart'; 3 | 4 | class DB extends StateNotifier { 5 | LazyBox? hive; 6 | CollectionBox? users; 7 | final Ref ref; 8 | DB(this.ref) : super(false); 9 | } 10 | 11 | final dbProvider = StateNotifierProvider((ref) => DB(ref)); 12 | -------------------------------------------------------------------------------- /lib/data/services/imdb_service.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 2 | import 'package:odin/data/entities/imdb.dart'; 3 | import 'package:odin/data/services/api.dart'; 4 | import 'package:odin/helpers.dart'; 5 | 6 | class ImdbService extends StateNotifier with BaseHelper { 7 | final ApiService api; 8 | Imdb? imdb; 9 | 10 | ImdbService(this.api) : super(false); 11 | 12 | Future getReviews(String id) async { 13 | var res = await api.get("/-/imdb/$id"); 14 | imdb = res.match((l) => Imdb(), (r) => Imdb.fromJson(r)); 15 | 16 | state = !state; 17 | } 18 | } 19 | 20 | final imdbProvider = StateNotifierProvider.autoDispose( 21 | (ref) => ImdbService(ref.watch(apiProvider))); 22 | -------------------------------------------------------------------------------- /lib/data/services/mqtt.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 2 | import 'package:mqtt_client/mqtt_client.dart'; 3 | import 'package:mqtt_client/mqtt_server_client.dart'; 4 | import 'package:odin/data/models/auth_model.dart'; 5 | import 'package:odin/helpers.dart'; 6 | 7 | class MQTTService with BaseHelper { 8 | MQTTService(this.auth); 9 | AuthModel auth; 10 | 11 | String? topic; 12 | 13 | late MqttServerClient client; 14 | 15 | Future initializeMQTTClient(String topic) async { 16 | this.topic = topic; 17 | final creds = await auth.getCredentials(); 18 | final uri = Uri.parse(creds["url"]); 19 | final host = uri.host; 20 | final proto = uri.scheme == "https" ? "wss://" : "ws://"; 21 | logInfo("$proto$host:${uri.port}/ws/mqtt"); 22 | 23 | client = MqttServerClient("$proto$host/ws/mqtt", 'odin-movieshows-app') 24 | ..port = uri.port 25 | ..logging(on: false) 26 | ..onDisconnected = onDisConnected 27 | ..setProtocolV311() 28 | ..onSubscribed = onSubscribed 29 | ..keepAlivePeriod = 60 * 15 30 | ..useWebSocket = true 31 | ..autoReconnect = true 32 | ..resubscribeOnAutoReconnect = true 33 | ..onConnected = onConnected; 34 | 35 | final connMess = MqttConnectMessage() 36 | .withClientIdentifier('odin-movieshows-app') 37 | .withWillTopic('willTopic') 38 | .withWillMessage('willMessage') 39 | .startClean() 40 | .withWillQos(MqttQos.atLeastOnce); 41 | log('Connecting....'); 42 | client.connectionMessage = connMess; 43 | await connectMQTT(); 44 | } 45 | 46 | Future connectMQTT() async { 47 | try { 48 | await client.connect(); 49 | } on NoConnectionException catch (e) { 50 | logWarning(e.toString()); 51 | client.disconnect(); 52 | } 53 | } 54 | 55 | void disConnectMQTT() { 56 | try { 57 | client.disconnect(); 58 | } catch (e) { 59 | logWarning(e.toString()); 60 | } 61 | } 62 | 63 | void subscirbe(String topic) { 64 | try { 65 | client.subscribe(topic, MqttQos.atLeastOnce); 66 | } catch (e) { 67 | logWarning(e.toString()); 68 | } 69 | } 70 | 71 | void unsubscribe(String topic) { 72 | try { 73 | client.unsubscribe(topic); 74 | } catch (e) { 75 | logWarning(e.toString()); 76 | } 77 | } 78 | 79 | void onConnected() { 80 | logOk('Connected'); 81 | 82 | try { 83 | client.subscribe(topic!, MqttQos.atLeastOnce); 84 | } catch (e) { 85 | logWarning(e.toString()); 86 | } 87 | } 88 | 89 | void onDisConnected() { 90 | logOk('Disconnected'); 91 | } 92 | 93 | void puslish(String message) { 94 | final builder = MqttClientPayloadBuilder(); 95 | builder.addString(message); 96 | 97 | client.publishMessage('sensor/home', MqttQos.atLeastOnce, builder.payload!); 98 | builder.clear(); 99 | } 100 | 101 | void onSubscribed(String topic) { 102 | logInfo(topic); 103 | } 104 | } 105 | 106 | final mqttProvider = 107 | Provider((ref) => MQTTService(ref.watch(authProvider.notifier))); 108 | -------------------------------------------------------------------------------- /lib/data/services/scrape_service.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 2 | import 'package:odin/data/entities/trakt.dart'; 3 | import 'package:odin/data/services/api.dart'; 4 | import 'package:odin/helpers.dart'; 5 | 6 | class ScrapeService extends StateNotifier with BaseHelper { 7 | final ApiService api; 8 | final Ref ref; 9 | ScrapeService(this.ref, this.api) : super(false); 10 | 11 | Future scrape( 12 | {required Trakt item, 13 | Trakt? show, 14 | Trakt? season, 15 | bool doCache = true}) async { 16 | Map data = {}; 17 | 18 | if (item.type == 'movie') { 19 | data = { 20 | 'type': 'movie', 21 | 'trakt': "${item.ids.trakt}", 22 | 'imdb': "${item.ids.imdb}", 23 | 'title': "${item.title}", 24 | 'year': "${item.year}", 25 | }; 26 | } else { 27 | data = { 28 | 'type': 'episode', 29 | 'show_imdb': "${show?.ids.imdb}", 30 | 'show_tvdb': "${show?.ids.tvdb}", 31 | 'show_title': "${show?.title}", 32 | 'show_year': "${show?.year}", 33 | 'season_number': "${season?.number}", 34 | 'episode_imdb': "${item.ids.imdb}", 35 | 'episode_trakt': "${item.ids.trakt}", 36 | 'episode_tvdb': "${item.ids.tvdb}", 37 | 'episode_title': "${item.title}", 38 | 'episode_number': "${item.number}", 39 | 'episode_year': "${item.year}", 40 | 'season_aired': "${season?.firstAired}", 41 | 'no_seasons': "${show?.seasons.length}", 42 | 'country': "${show?.language}", 43 | }; 44 | } 45 | logInfo(data); 46 | 47 | await api.post('/-/scrape', data); 48 | } 49 | } 50 | 51 | final scrapeProvider = 52 | Provider((ref) => ScrapeService(ref, ref.watch(apiProvider))); 53 | -------------------------------------------------------------------------------- /lib/data/services/tmdb_service.dart: -------------------------------------------------------------------------------- 1 | import 'package:dio/dio.dart'; 2 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 | import 'package:odin/data/models/auth_model.dart'; 4 | import 'package:odin/helpers.dart'; 5 | 6 | class TmdbService with BaseHelper { 7 | String url = 'https://api.themoviedb.org/3'; 8 | String imageURL = 'https://image.tmdb.org/t/p/w500'; 9 | final AuthModel auth; 10 | final Ref ref; 11 | Dio dio = Dio(); 12 | 13 | TmdbService(this.ref, this.auth) { 14 | dio.options.baseUrl = url; 15 | dio.interceptors.add(InterceptorsWrapper(onError: (e, handler) { 16 | logWarning(e.requestOptions.uri); 17 | logError(e, e.stackTrace); 18 | return handler.next(e); 19 | }, onRequest: (options, handler) async { 20 | var creds = await auth.getCredentials(); 21 | var apiUrl = creds["url"]; 22 | var device = creds["device"]; 23 | options.baseUrl = '$apiUrl'; 24 | options.headers.addAll({'Device': device}); 25 | return handler.next(options); 26 | })); 27 | } 28 | 29 | Future>> getEpisodeImages( 30 | int showId, List seasons) async { 31 | final res = 32 | await dio.get('/-/tmdbseasons/$showId?seasons=${seasons.join(',')}'); 33 | Map> images = {}; 34 | try { 35 | List list = res.data; 36 | 37 | for (var s in List.from(list)) { 38 | int sn = s['season_number']; 39 | images[sn] = {}; 40 | Map results = {}; 41 | for (var e in s['episodes']) { 42 | if (e['episode_number'] != null && e['still_path'] != null) { 43 | int en = e['episode_number']; 44 | results[en] = e['still_path']; 45 | } 46 | } 47 | images[sn] = results; 48 | } 49 | return images; 50 | } catch (e) { 51 | return {}; 52 | } 53 | } 54 | } 55 | 56 | final tmdbProvider = 57 | Provider((ref) => TmdbService(ref, ref.watch(authProvider.notifier))); 58 | -------------------------------------------------------------------------------- /lib/data/services/trakt_service.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:convert'; 3 | import 'package:flutter/foundation.dart'; 4 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 5 | import 'package:odin/data/entities/tmdb.dart'; 6 | import 'package:odin/data/entities/trakt.dart'; 7 | import 'package:odin/data/entities/user.dart'; 8 | import 'package:odin/data/models/settings_model.dart'; 9 | import 'package:odin/data/services/api.dart'; 10 | import 'package:odin/data/services/tmdb_service.dart'; 11 | import 'package:odin/helpers.dart'; 12 | 13 | Map _parseAndDecodeList(String response) { 14 | return jsonDecode(response) as Map; 15 | } 16 | 17 | Future> parseJsonList(String text) { 18 | return compute(_parseAndDecodeList, text); 19 | } 20 | 21 | class TraktService with BaseHelper { 22 | final Ref ref; 23 | SettingsModel settings; 24 | final ApiService api; 25 | 26 | WatchedItems watchedItems; 27 | 28 | TraktService(this.ref, this.watchedItems, this.api, this.settings); 29 | 30 | Future setWatched( 31 | {required Trakt item, Trakt? show, Trakt? season}) async { 32 | String key = ''; 33 | if (item.type == 'movie') { 34 | key = BaseHelper.hiveKey('movie', item.ids.trakt); 35 | } else { 36 | key = BaseHelper.hiveKey( 37 | 'show', show!.ids.trakt, season!.number, item.number); 38 | } 39 | watchedItems.add(key); 40 | } 41 | 42 | Future addToCollection(dynamic data) async { 43 | await api.post("/-/trakt/sync/collection", data); 44 | } 45 | 46 | Future addToWatchlist(dynamic data) async { 47 | // final res = await dio.post('/sync/watchlist', data: data); 48 | return (await api.post("/-/trakt/sync/watchlist", data)) 49 | .match((l) => null, (r) => r); 50 | } 51 | 52 | Future _scrobble(String endpoint, dynamic data) async { 53 | if (!settings.config.scrobble) return; 54 | return (await api.post("/-/trakt/scrobble/$endpoint", data)) 55 | .match((l) => null, (r) => r); 56 | } 57 | 58 | void watching(Trakt item, int progress, String action) { 59 | final data = { 60 | item.type == 'movie' ? 'movie' : 'episode': _traktObject(item), 61 | 'progress': progress 62 | }; 63 | _scrobble(action, data); 64 | } 65 | 66 | Future getUser() async { 67 | return (await api.get("/-/trakt/users/settings")) 68 | .match((l) => User(), (r) => User.fromTrakt(r)); 69 | } 70 | 71 | Map _traktObject(Trakt m) { 72 | return { 73 | 'title': m.title, 74 | 'year': m.year, 75 | 'ids': { 76 | 'imdb': m.ids.imdb, 77 | 'trakt': m.ids.trakt, 78 | 'tmdb': m.ids.tmdb, 79 | 'slug': m.ids.slug 80 | } 81 | }; 82 | } 83 | 84 | Future> getSeasons(int showId) async { 85 | return (await api.get("/-/traktseasons/$showId")) 86 | .match((l) => [], (r) => _getItems(r)); 87 | } 88 | 89 | Future> _getItems(dynamic map) async { 90 | List list = List.from(map).map((e) { 91 | var elem = e; 92 | var t = Trakt.fromJson(elem); 93 | 94 | if (e['episode'] != null) { 95 | TraktEpisode episode = TraktEpisode.fromJson(e['episode']); 96 | t.episode = episode; 97 | } 98 | return t; 99 | }).toList(); 100 | 101 | List newList = await Future.wait(list.map((Trakt t) async { 102 | if (t.type == 'movie' && !t.watched) { 103 | t.setWatched(watchedItems.items 104 | .contains(BaseHelper.hiveKey('movie', t.ids.trakt))); 105 | } 106 | 107 | if (t.episode != null && !t.watched) { 108 | t.setWatched(watchedItems.items.contains(BaseHelper.hiveKey( 109 | 'show', t.ids.trakt, t.episode!.season, t.episode!.number))); 110 | } 111 | return t; 112 | })); 113 | 114 | list.clear(); 115 | 116 | return newList; 117 | } 118 | 119 | Future> getItems(String endpoint) async { 120 | return (await api.get("/-/trakt/$endpoint")) 121 | .match((l) => [], (r) => _getItems(r)); 122 | } 123 | } 124 | 125 | final traktProvider = Provider((ref) => TraktService( 126 | ref, 127 | ref.watch(watchedProvider.notifier), 128 | ref.watch(apiProvider), 129 | ref.watch(settingsProvider))); 130 | 131 | class WatchedItems extends StateNotifier { 132 | List items = []; 133 | WatchedItems() : super(false); 134 | 135 | void add(String key) { 136 | items.add(key); 137 | state = !state; 138 | } 139 | } 140 | 141 | final watchedProvider = StateNotifierProvider((ref) => WatchedItems()); 142 | final seasonsProvider = 143 | FutureProviderFamily, TraktIds>((ref, ids) async { 144 | final seasons = await ref.watch(traktProvider).getSeasons(ids.trakt); 145 | final episodeImages = await ref 146 | .watch(tmdbProvider) 147 | .getEpisodeImages(ids.tmdb, seasons.map((s) => s.number).toList()); 148 | for (int i = 0; i < seasons.length; i++) { 149 | final sn = seasons[i].number; 150 | for (int e = 0; e < seasons[i].episodes.length; e++) { 151 | final en = seasons[i].episodes[e].number; 152 | if (episodeImages[sn] != null && episodeImages[sn]![en] != null) { 153 | seasons[i].episodes[e].tmdb = Tmdb(stillPath: episodeImages[sn]![en]!); 154 | } 155 | } 156 | } 157 | return seasons; 158 | }); 159 | -------------------------------------------------------------------------------- /lib/helpers.dart: -------------------------------------------------------------------------------- 1 | import 'dart:developer' as developer; 2 | 3 | import 'package:flutter/foundation.dart'; 4 | import 'package:logging/logging.dart'; 5 | 6 | class DLogger { 7 | static void log(dynamic message) { 8 | DebugLogger().log(message, ""); 9 | } 10 | 11 | static void logWarning(dynamic message) { 12 | DebugLogger().warn(message, ""); 13 | } 14 | 15 | static void logOk(dynamic message) { 16 | DebugLogger().ok(message, ""); 17 | } 18 | 19 | static void logError(dynamic message, dynamic stack) { 20 | DebugLogger().error(message, "", stack); 21 | } 22 | 23 | static void logInfo(dynamic message) { 24 | DebugLogger().info(message, ""); 25 | } 26 | 27 | static void logFatal(dynamic message, Error e, StackTrace s) { 28 | DebugLogger().fatal(message, "", e, s); 29 | } 30 | } 31 | 32 | class BaseHelper { 33 | void log(dynamic message) { 34 | DebugLogger().log(message, runtimeType.toString()); 35 | } 36 | 37 | void logWarning(dynamic message) { 38 | DebugLogger().warn(message, runtimeType.toString()); 39 | } 40 | 41 | void logOk(dynamic message) { 42 | DebugLogger().ok(message, runtimeType.toString()); 43 | } 44 | 45 | void logError(dynamic message, dynamic stack) { 46 | DebugLogger().error(message, runtimeType.toString(), stack); 47 | } 48 | 49 | void logInfo(dynamic message) { 50 | DebugLogger().info(message, runtimeType.toString()); 51 | } 52 | 53 | void logFatal(dynamic message, Error e, StackTrace s) { 54 | DebugLogger().fatal(message, runtimeType.toString(), e, s); 55 | } 56 | 57 | static String hiveKey(String type, int id, [int? season, int? episode]) { 58 | return type == 'movie' 59 | ? 'movie-$id' 60 | : episode != null 61 | ? 'show-${id}s${season}e$episode' 62 | : 'show-${id}s$season'; 63 | } 64 | 65 | String filesize(dynamic size, [int round = 2]) { 66 | /** 67 | * [size] can be passed as number or as string 68 | * 69 | * the optional parameter [round] specifies the number 70 | * of digits after comma/point (default is 2) 71 | */ 72 | var divider = 1024; 73 | int size0; 74 | try { 75 | size0 = int.parse(size.toString()); 76 | } catch (e) { 77 | throw ArgumentError('Can not parse the size parameter: $e'); 78 | } 79 | 80 | if (size0 < divider) { 81 | return '$size0 B'; 82 | } 83 | 84 | if (size0 < divider * divider && size0 % divider == 0) { 85 | return '${(size0 / divider).toStringAsFixed(0)} KB'; 86 | } 87 | 88 | if (size0 < divider * divider) { 89 | return '${(size0 / divider).toStringAsFixed(round)} KB'; 90 | } 91 | 92 | if (size0 < divider * divider * divider && size0 % divider == 0) { 93 | return '${(size0 / (divider * divider)).toStringAsFixed(0)} MB'; 94 | } 95 | 96 | if (size0 < divider * divider * divider) { 97 | return '${(size0 / divider / divider).toStringAsFixed(round)} MB'; 98 | } 99 | 100 | if (size0 < divider * divider * divider * divider && size0 % divider == 0) { 101 | return '${(size0 / (divider * divider * divider)).toStringAsFixed(0)} GB'; 102 | } 103 | 104 | if (size0 < divider * divider * divider * divider) { 105 | return '${(size0 / divider / divider / divider).toStringAsFixed(round)} GB'; 106 | } 107 | 108 | if (size0 < divider * divider * divider * divider * divider && 109 | size0 % divider == 0) { 110 | num r = size0 / divider / divider / divider / divider; 111 | return '${r.toStringAsFixed(0)} TB'; 112 | } 113 | 114 | if (size0 < divider * divider * divider * divider * divider) { 115 | num r = size0 / divider / divider / divider / divider; 116 | return '${r.toStringAsFixed(round)} TB'; 117 | } 118 | 119 | if (size0 < divider * divider * divider * divider * divider * divider && 120 | size0 % divider == 0) { 121 | num r = size0 / divider / divider / divider / divider / divider; 122 | return '${r.toStringAsFixed(0)} PB'; 123 | } else { 124 | num r = size0 / divider / divider / divider / divider / divider; 125 | return '${r.toStringAsFixed(round)} PB'; 126 | } 127 | } 128 | } 129 | 130 | class DebugLogger { 131 | static DebugLogger? _instance; 132 | static Logger? _logger; 133 | factory DebugLogger() => _instance ?? DebugLogger._internal(); 134 | String _name = ''; 135 | 136 | DebugLogger._internal() { 137 | Logger.root.level = Level.ALL; 138 | Logger.root.onRecord.listen(_recordHandler); 139 | 140 | _logger = Logger('Blitzcoin '); 141 | 142 | _instance = this; 143 | } 144 | 145 | void _recordHandler(LogRecord record) { 146 | if (!kDebugMode) { 147 | return; 148 | } 149 | hierarchicalLoggingEnabled = true; 150 | 151 | var start = '\x1b[90m'; 152 | const end = '\x1b[0m'; 153 | const prefix = '\x1b[38;5;240m'; 154 | 155 | var emoji = ''; 156 | 157 | switch (record.level.name) { 158 | case 'FINE': 159 | start = '\x1b[38;5;2m'; 160 | emoji = '✓ OK'; 161 | break; 162 | case 'FINEST': 163 | emoji = '●'; 164 | start = '\x1b[38;5;7m'; 165 | break; 166 | case 'INFO': 167 | emoji = '★ INFO'; 168 | start = '\x1b[38;5;6m'; 169 | break; 170 | case 'WARNING': 171 | emoji = 'ϟ WARN'; 172 | start = '\x1b[38;5;222m'; 173 | break; 174 | case 'SEVERE': 175 | emoji = '✖ SEVERE'; 176 | start = '\x1b[38;5;1m'; 177 | break; 178 | case 'SHOUT': 179 | emoji = '❕SHOUT'; 180 | start = '\x1b[48;5;1m\x1b[37m'; 181 | break; 182 | } 183 | 184 | final message = 185 | '$prefix${_name.padRight(20)} $end$start ${record.message}$end'; 186 | developer.log(message, 187 | name: '$start$emoji$end$prefix', 188 | level: record.level.value, 189 | time: record.time, 190 | stackTrace: record.stackTrace); 191 | } 192 | 193 | void log(dynamic message, String name) { 194 | _name = name; 195 | _logger?.finest(message); 196 | } 197 | 198 | void info(dynamic message, String name) { 199 | _name = name; 200 | _logger?.info(message); 201 | } 202 | 203 | void warn(dynamic message, String name) { 204 | _name = name; 205 | _logger?.warning(message); 206 | } 207 | 208 | void ok(dynamic message, String name) { 209 | _name = name; 210 | _logger?.fine(message); 211 | } 212 | 213 | void error(dynamic message, String name, dynamic stack) { 214 | _name = name; 215 | _logger?.severe(message); 216 | if (stack != null) { 217 | if (kDebugMode) { 218 | print(stack); 219 | } 220 | } 221 | } 222 | 223 | void fatal(dynamic message, String name, Error e, StackTrace s) { 224 | _name = name; 225 | _logger?.shout(message, e, s); 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /lib/main.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter/services.dart'; 3 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 4 | import 'package:helpers/helpers.dart'; 5 | import 'package:hive_ce_flutter/hive_flutter.dart'; 6 | import 'package:odin/data/models/auth_model.dart'; 7 | import 'package:odin/data/services/db.dart'; 8 | import 'package:odin/helpers.dart'; 9 | import 'package:odin/theme.dart'; 10 | import 'package:odin/ui/app.dart'; 11 | import 'package:odin/ui/login.dart'; 12 | import 'package:odin/ui/selectbuttonfixer.dart'; 13 | import 'package:odin/ui/userselect.dart'; 14 | import 'package:odin/ui/widgets/widgets.dart'; 15 | 16 | class PVLogger extends ProviderObserver with BaseHelper { 17 | @override 18 | void didUpdateProvider( 19 | ProviderBase provider, 20 | Object? previousValue, 21 | Object? newValue, 22 | ProviderContainer container, 23 | ) { 24 | // logWarning(provider.argument ?? provider.name ?? provider.runtimeType); 25 | } 26 | 27 | @override 28 | void didAddProvider(ProviderBase provider, Object? value, 29 | ProviderContainer container) { 30 | // log(provider.argument ?? provider.name ?? provider.runtimeType); 31 | } 32 | 33 | @override 34 | void didDisposeProvider( 35 | ProviderBase provider, ProviderContainer container) { 36 | // logOk(provider.argument ?? provider.name ?? provider.runtimeType); 37 | } 38 | } 39 | 40 | final selectButtonProvider = StateProvider((ref) { 41 | return 0; 42 | }); 43 | 44 | final initProvider = StreamProvider((ref) async* { 45 | final sb = ref.watch(selectButtonProvider); 46 | await Hive.initFlutter(); 47 | final db = ref.read(dbProvider.notifier); 48 | final collection = await BoxCollection.open( 49 | 'OdinBox', 50 | {'users'}, 51 | path: './', 52 | ); 53 | db.users = await collection.openBox("users"); 54 | db.hive = await Hive.openLazyBox("odin"); 55 | int select = (await db.hive?.get("selectKey")) ?? 0; 56 | if (sb > 0) { 57 | await db.hive?.put("selectKey", sb); 58 | select = sb; 59 | } 60 | final auth = ref.read(authProvider.notifier); 61 | await auth.check(); 62 | yield select != 0; 63 | }); 64 | 65 | void main() async { 66 | WidgetsFlutterBinding.ensureInitialized(); 67 | runApp(ProviderScope(observers: [PVLogger()], child: const MyApp())); 68 | } 69 | 70 | class LoadingScreen extends StatelessWidget { 71 | final String title; 72 | const LoadingScreen( 73 | {Key? key, this.title = "Enjoy your favorite movies and tv shows"}) 74 | : super(key: key); 75 | @override 76 | Widget build(BuildContext context) { 77 | return Container( 78 | color: AppColors.darkGray, 79 | child: Center( 80 | child: Column( 81 | mainAxisSize: MainAxisSize.min, 82 | children: [ 83 | const OdinLogo(height: 50), 84 | const SizedBox(height: 15), 85 | BodyText1(title), 86 | const SizedBox(height: 50), 87 | SizedBox( 88 | width: 20, 89 | height: 20, 90 | child: CircularProgressIndicator( 91 | color: AppColors.red, 92 | ), 93 | ), 94 | ], 95 | )), 96 | ); 97 | } 98 | } 99 | 100 | class AppBasedOnAuth extends ConsumerWidget { 101 | @override 102 | Widget build(BuildContext context, ref) { 103 | final auth = ref.watch(authProvider); 104 | switch (auth) { 105 | case AuthState.login: 106 | return const Login(); 107 | case AuthState.multiple: 108 | return const UserSelect(); 109 | case AuthState.error: 110 | return const LoadingScreen( 111 | title: "Error: Cannot connect to the API.", 112 | ); 113 | } 114 | return const App(); 115 | } 116 | } 117 | 118 | class MyApp extends ConsumerWidget { 119 | const MyApp({Key? key}) : super(key: key); 120 | 121 | // This widget is the root of your application. 122 | @override 123 | Widget build(BuildContext context, ref) { 124 | final init = ref.watch(initProvider); 125 | 126 | return Shortcuts( 127 | shortcuts: { 128 | LogicalKeySet(LogicalKeyboardKey.select): const ActivateIntent(), 129 | }, 130 | child: MaterialApp( 131 | // initialBinding: AppBinding(), 132 | debugShowCheckedModeBanner: false, 133 | title: 'Odin', 134 | theme: AppThemes.defaultTheme, 135 | themeMode: ThemeMode.dark, 136 | home: init.when( 137 | data: (value) => 138 | !value ? const SelectButtonFixer() : AppBasedOnAuth(), 139 | error: (_, __) => Container(), 140 | loading: () => Container( 141 | color: AppColors.darkGray, 142 | child: const LoadingScreen(), 143 | )), 144 | // locale: Locale('en', 'US'), 145 | ), 146 | ); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /lib/main.reflectable.dart: -------------------------------------------------------------------------------- 1 | // No output from reflectable, 'package:reflectable/reflectable.dart' not used. -------------------------------------------------------------------------------- /lib/theme.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:google_fonts/google_fonts.dart'; 3 | 4 | class AppThemes { 5 | // static ThemeData otherTheme = const ThemeData(primaryColor: Colors.blue) 6 | 7 | static ThemeData defaultTheme = ThemeData( 8 | // primarySwatch: Colors.orange, 9 | appBarTheme: AppBarTheme( 10 | color: AppColors.darkGray, 11 | elevation: 0, 12 | ), 13 | primaryColor: AppColors.primary, 14 | 15 | // accentColor: AppColors.primary, 16 | visualDensity: VisualDensity.adaptivePlatformDensity, 17 | fontFamily: GoogleFonts.lato().fontFamily, 18 | textButtonTheme: TextButtonThemeData( 19 | style: ButtonStyle( 20 | elevation: const WidgetStatePropertyAll(0), 21 | padding: const WidgetStatePropertyAll( 22 | EdgeInsets.symmetric(horizontal: 15, vertical: 2)), 23 | backgroundColor: WidgetStatePropertyAll(Colors.white.withAlpha(0)), 24 | foregroundColor: WidgetStatePropertyAll(AppColors.primary), 25 | overlayColor: 26 | WidgetStatePropertyAll(AppColors.primary.withAlpha(30)), 27 | textStyle: 28 | WidgetStatePropertyAll(TextStyle(color: AppColors.primary)), 29 | shape: WidgetStatePropertyAll( 30 | RoundedRectangleBorder( 31 | borderRadius: BorderRadius.circular(50), 32 | side: BorderSide(color: Colors.white.withAlpha(0), width: 1), 33 | ), 34 | ) 35 | // shape: MaterialStateProperty.all(Border.cir) 36 | ), 37 | ), 38 | buttonTheme: ButtonThemeData( 39 | focusColor: Colors.white.withAlpha(100), 40 | highlightColor: Colors.transparent, 41 | splashColor: Colors.transparent, 42 | buttonColor: Colors.white, 43 | textTheme: ButtonTextTheme.primary, 44 | // textTheme: TextTheme, 45 | shape: RoundedRectangleBorder( 46 | borderRadius: BorderRadius.circular(50), 47 | side: const BorderSide(color: Colors.white))), 48 | tabBarTheme: TabBarTheme( 49 | labelColor: Colors.white, 50 | labelPadding: const EdgeInsets.all(0), 51 | unselectedLabelColor: Colors.white, 52 | indicatorSize: TabBarIndicatorSize.label, 53 | indicator: BoxDecoration( 54 | borderRadius: BorderRadius.circular(50), 55 | color: Colors.white.withAlpha(50)), 56 | ), 57 | textTheme: TextTheme( 58 | bodySmall: const TextStyle(color: Colors.white, fontSize: 10), 59 | titleMedium: TextStyle(color: AppColors.gray2, fontSize: 10), 60 | titleSmall: const TextStyle(color: Colors.white, fontSize: 10), 61 | displayLarge: TextStyle( 62 | color: Colors.white, 63 | fontSize: 35, 64 | fontFamily: 65 | GoogleFonts.montserrat(fontWeight: FontWeight.w700).fontFamily, 66 | ), 67 | displayMedium: TextStyle( 68 | color: Colors.white, 69 | fontSize: 25, 70 | fontFamily: 71 | GoogleFonts.montserrat(fontWeight: FontWeight.w700).fontFamily, 72 | ), 73 | displaySmall: TextStyle( 74 | color: Colors.white, 75 | fontSize: 18, 76 | fontFamily: 77 | GoogleFonts.montserrat(fontWeight: FontWeight.w700).fontFamily, 78 | ), 79 | headlineMedium: TextStyle( 80 | color: Colors.white, 81 | fontSize: 13, 82 | fontFamily: 83 | GoogleFonts.montserrat(fontWeight: FontWeight.w700).fontFamily, 84 | ), 85 | headlineSmall: TextStyle( 86 | color: AppColors.gray2, 87 | fontSize: 12, 88 | fontFamily: 89 | GoogleFonts.montserrat(fontWeight: FontWeight.w400).fontFamily, 90 | ), 91 | titleLarge: TextStyle( 92 | color: AppColors.gray3, 93 | fontSize: 13, 94 | fontFamily: 95 | GoogleFonts.montserrat(fontWeight: FontWeight.w400).fontFamily, 96 | ), 97 | bodyLarge: const TextStyle( 98 | color: Colors.white, 99 | fontSize: 12, 100 | height: 1.5, 101 | ), 102 | bodyMedium: const TextStyle(color: Colors.white, fontSize: 12), 103 | ), 104 | drawerTheme: DrawerThemeData( 105 | shape: RoundedRectangleBorder( 106 | borderRadius: BorderRadius.circular(0), 107 | ), 108 | ), 109 | scaffoldBackgroundColor: AppColors.darkGray); 110 | } 111 | 112 | class AppColors { 113 | static Color primary = orange; 114 | static Color darkGray = const Color.fromRGBO(17, 17, 27, 1); 115 | static Color lightGray = gray2; 116 | static Color turquoise = const Color.fromRGBO(42, 195, 222, 1); 117 | static Color red = const Color.fromRGBO(247, 118, 142, 1); 118 | static Color green = const Color.fromRGBO(158, 206, 106, 1); 119 | static Color yellow = const Color.fromRGBO(224, 175, 104, 1); 120 | static Color orange = const Color.fromRGBO(255, 158, 100, 1); 121 | static Color blue = const Color.fromRGBO(125, 207, 255, 1); 122 | static Color purple = const Color.fromRGBO(187, 154, 247, 1); 123 | static Color gray1 = const Color.fromRGBO(205, 214, 244, 1); 124 | static Color gray2 = const Color.fromRGBO(86, 95, 137, 1); 125 | static Color gray3 = const Color.fromRGBO(65, 72, 104, 1); 126 | static Color gray4 = const Color.fromRGBO(24, 24, 37, 1); 127 | } 128 | -------------------------------------------------------------------------------- /lib/ui/appbar.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 | import 'package:font_awesome_flutter/font_awesome_flutter.dart'; 4 | import 'package:helpers/helpers.dart'; 5 | import 'package:odin/theme.dart'; 6 | import 'package:odin/ui/focusnodes.dart'; 7 | import 'package:odin/ui/widgets/widgets.dart'; 8 | 9 | import '../controllers/app_controller.dart'; 10 | 11 | class OdinAppBar extends ConsumerWidget { 12 | const OdinAppBar({Key? key}) : super(key: key); 13 | 14 | @override 15 | Widget build(BuildContext context, ref) { 16 | return AppBar( 17 | backgroundColor: Colors.transparent, 18 | actions: [ 19 | Builder( 20 | builder: (context) => IconButton( 21 | padding: const EdgeInsets.all(5), 22 | focusColor: Colors.white.withAlpha(40), 23 | splashRadius: 20, 24 | focusNode: menufocus[0], 25 | icon: const Icon(FontAwesomeIcons.gear, size: 16), 26 | onPressed: () => Scaffold.of(context).openEndDrawer(), 27 | ), 28 | ), 29 | ], 30 | elevation: 0, 31 | title: Row( 32 | mainAxisSize: MainAxisSize.min, 33 | mainAxisAlignment: MainAxisAlignment.spaceAround, 34 | children: [ 35 | const OdinLogo(), 36 | const SizedBox(width: 20), 37 | Flexible( 38 | child: SizedBox( 39 | height: 20, 40 | child: Row( 41 | children: [ 42 | TextButton( 43 | focusNode: menufocus[1], 44 | child: BodyText1( 45 | 'Home', 46 | style: TextStyle(fontSize: 10, color: AppColors.purple), 47 | ), 48 | onPressed: () { 49 | ref.read(appPageProvider.notifier).state = 0; 50 | }, 51 | ), 52 | const SizedBox(width: 5), 53 | TextButton( 54 | focusNode: menufocus[2], 55 | child: BodyText1( 56 | 'Movies', 57 | style: TextStyle(fontSize: 10, color: AppColors.purple), 58 | ), 59 | onPressed: () { 60 | ref.read(appPageProvider.notifier).state = 1; 61 | }, 62 | ), 63 | const SizedBox(width: 5), 64 | TextButton( 65 | focusNode: menufocus[3], 66 | child: BodyText1( 67 | 'TV Shows', 68 | style: TextStyle(fontSize: 10, color: AppColors.purple), 69 | ), 70 | onPressed: () { 71 | ref.read(appPageProvider.notifier).state = 2; 72 | }, 73 | ), 74 | ], 75 | ), 76 | )), 77 | const Expanded(child: SizedBox()) 78 | ], 79 | )); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /lib/ui/cover/animated.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_animate/flutter_animate.dart'; 3 | import 'package:infinite_carousel/infinite_carousel.dart'; 4 | 5 | class AnimatedCover extends StatelessWidget { 6 | final Widget? child; 7 | final InfiniteScrollController? controller; 8 | final double? extent; 9 | final int? realIndex; 10 | final bool? target; 11 | const AnimatedCover( 12 | {Key? key, 13 | this.child, 14 | this.controller, 15 | this.extent, 16 | this.realIndex, 17 | this.target}) 18 | : super(key: key); 19 | 20 | @override 21 | Widget build(BuildContext context) { 22 | final currentOffset = extent! * realIndex!; 23 | const maxScale = 1; 24 | const fallOff = 0.2; 25 | const minScale = 0.90; 26 | return AnimatedBuilder( 27 | animation: controller!, 28 | builder: (context, child) { 29 | final diff = (controller!.offset - currentOffset); 30 | 31 | final carouselRatio = extent! / fallOff; 32 | // double r = (maxScale - (diff / carouselRatio)); 33 | double s = (maxScale - (diff / carouselRatio).abs()); 34 | 35 | double f = s; 36 | double b = s; 37 | double o = s; 38 | if (s < minScale) { 39 | s = minScale; 40 | } 41 | 42 | if (f < 0.4) { 43 | f = 0.4; 44 | } 45 | 46 | if (b < 0.2) { 47 | b = 0.2; 48 | } 49 | 50 | return child! 51 | .animate() 52 | // .blurXY(end: 0, begin: 5) 53 | .animate(target: target! ? 1 : 0) 54 | .scaleXY(end: s, begin: minScale, curve: Curves.easeInOutExpo) 55 | .tint(end: 1 - f, begin: 1 - f) 56 | 57 | //.blurXY(end: 1.3 - (1.3 * b), begin: 1.3 - (1.3 * b)) 58 | .fade(end: f, begin: f); 59 | // .flipH(end: 1.5 - (1.5 * s)) 60 | }, 61 | child: child!, 62 | ); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /lib/ui/cover/backdrop_cover.dart: -------------------------------------------------------------------------------- 1 | import 'package:cached_network_image/cached_network_image.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:flutter_hooks/flutter_hooks.dart'; 4 | import 'package:flutter_svg/flutter_svg.dart'; 5 | import 'package:font_awesome_flutter/font_awesome_flutter.dart'; 6 | import 'package:helpers/helpers.dart'; 7 | import 'package:helpers/helpers/widgets/text.dart'; 8 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 9 | import 'package:odin/controllers/app_controller.dart'; 10 | import 'package:odin/data/entities/trakt.dart'; 11 | import 'package:odin/data/services/trakt_service.dart'; 12 | import 'package:odin/helpers.dart'; 13 | import 'package:odin/theme.dart'; 14 | import 'package:odin/ui/detail/detail.dart'; 15 | import 'package:odin/ui/widgets/widgets.dart'; 16 | 17 | class BackdropCover extends HookConsumerWidget { 18 | final Trakt item; 19 | 20 | const BackdropCover(this.item, {Key? key}) : super(key: key); 21 | 22 | @override 23 | Widget build(BuildContext context, ref) { 24 | // useAutomaticKeepAlive(); 25 | // useSingleTickerProvider(); 26 | return Stack( 27 | alignment: Alignment.topLeft, 28 | children: [ 29 | CachedNetworkImage( 30 | imageUrl: item.tmdb!.backdropSmall, 31 | errorWidget: (_, __, ___) => Container( 32 | decoration: BoxDecoration( 33 | color: Colors.black, borderRadius: BorderRadius.circular(5)), 34 | child: Icon( 35 | FontAwesomeIcons.image, 36 | color: AppColors.primary, 37 | size: 30, 38 | ), 39 | ), 40 | imageBuilder: (context, imageProvider) => Container( 41 | decoration: BoxDecoration( 42 | boxShadow: [ 43 | BoxShadow( 44 | color: Colors.black.withAlpha(180), 45 | blurRadius: 10, 46 | spreadRadius: -10, 47 | offset: const Offset(10, 15)) 48 | ], 49 | ), 50 | // padding: const EdgeInsets.all(2), 51 | child: ClipRRect( 52 | borderRadius: BorderRadius.circular(10), 53 | child: Image( 54 | image: imageProvider, 55 | ), 56 | )), 57 | placeholder: (_, __) => Container( 58 | decoration: BoxDecoration( 59 | color: Colors.black, borderRadius: BorderRadius.circular(5)), 60 | child: Icon( 61 | FontAwesomeIcons.image, 62 | color: AppColors.darkGray, 63 | size: 30, 64 | ), 65 | ), 66 | fit: BoxFit.fill, 67 | ), 68 | Container( 69 | height: 127, 70 | decoration: BoxDecoration( 71 | borderRadius: BorderRadius.circular(7), 72 | gradient: LinearGradient( 73 | begin: Alignment.topCenter, 74 | end: Alignment.bottomCenter, 75 | colors: [ 76 | Colors.transparent, 77 | AppColors.darkGray.withAlpha(50), 78 | AppColors.darkGray.withAlpha(230), 79 | 80 | // AppColors.darkGray.withAlpha(250), 81 | ])), 82 | ), 83 | Padding( 84 | padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 0), 85 | child: Column( 86 | crossAxisAlignment: CrossAxisAlignment.start, 87 | children: [ 88 | const SizedBox(height: 55), 89 | item.tmdb!.logoSmall.endsWith('.svg') 90 | ? SvgPicture.network( 91 | item.tmdb!.logoSmall, 92 | width: 80, 93 | height: 40, 94 | fit: BoxFit.contain, 95 | ) 96 | : CachedNetworkImage( 97 | imageUrl: item.tmdb!.logoSmall, 98 | errorWidget: (_, __, ___) => const SizedBox(height: 50), 99 | imageBuilder: (context, imageProvider) => Image( 100 | image: imageProvider, 101 | fit: BoxFit.contain, 102 | width: 80, 103 | height: 40, 104 | ), 105 | placeholder: (_, __) => const SizedBox(height: 50), 106 | fit: BoxFit.fill, 107 | ), 108 | const SizedBox(height: 15), 109 | item.number > 0 110 | ? CaptionText( 111 | 'S${item.season}E${item.number} - ${item.title}', 112 | style: TextStyle( 113 | color: AppColors.gray1, 114 | overflow: TextOverflow.ellipsis), 115 | ) 116 | : Row( 117 | children: [ 118 | Subtitle1(item.year.toString(), 119 | style: TextStyle( 120 | color: AppColors.gray1, 121 | fontSize: 8, 122 | fontWeight: FontWeight.w600)), 123 | const SizedBox(width: 12), 124 | Row( 125 | children: [ 126 | Icon( 127 | FontAwesomeIcons.solidStar, 128 | size: 8, 129 | color: AppColors.primary, 130 | ), 131 | const SizedBox(width: 5), 132 | Subtitle1( 133 | item.roundedRating.toString(), 134 | style: TextStyle( 135 | color: AppColors.primary, 136 | fontSize: 8, 137 | fontWeight: FontWeight.w600), 138 | ), 139 | const SizedBox(width: 135), 140 | ref.watch(watchedProvider.notifier).items.contains( 141 | BaseHelper.hiveKey( 142 | item.type, item.ids.trakt)) || 143 | item.watched 144 | ? const Watched( 145 | iconOnly: true, 146 | ) 147 | : const SizedBox() 148 | ], 149 | ), 150 | ], 151 | ) 152 | ], 153 | ), 154 | ), 155 | ], 156 | ); 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /lib/ui/cover/poster_cover.dart: -------------------------------------------------------------------------------- 1 | import 'package:cached_network_image/cached_network_image.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:flutter_hooks/flutter_hooks.dart'; 4 | import 'package:font_awesome_flutter/font_awesome_flutter.dart'; 5 | import 'package:helpers/helpers/widgets/text.dart'; 6 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 7 | import 'package:odin/controllers/app_controller.dart'; 8 | import 'package:odin/data/entities/trakt.dart'; 9 | import 'package:odin/data/services/trakt_service.dart'; 10 | import 'package:odin/helpers.dart'; 11 | import 'package:odin/theme.dart'; 12 | import 'package:odin/ui/detail/detail.dart'; 13 | import 'package:odin/ui/widgets/widgets.dart'; 14 | 15 | class PosterCover extends HookConsumerWidget { 16 | final Trakt item; 17 | const PosterCover(this.item, {Key? key}) : super(key: key); 18 | 19 | @override 20 | Widget build(BuildContext context, ref) { 21 | // useAutomaticKeepAlive(); 22 | // useSingleTickerProvider(); 23 | return Stack( 24 | children: [ 25 | CachedNetworkImage( 26 | imageUrl: item.tmdb!.posterSmall, 27 | errorWidget: (_, str, d) => Container( 28 | width: 100, 29 | height: 135, 30 | decoration: BoxDecoration( 31 | color: Colors.black, 32 | borderRadius: BorderRadius.circular(5), 33 | ), 34 | child: Icon( 35 | FontAwesomeIcons.image, 36 | color: AppColors.primary, 37 | size: 30, 38 | ), 39 | ), 40 | imageBuilder: (context, imageProvider) => Container( 41 | decoration: BoxDecoration( 42 | boxShadow: [ 43 | BoxShadow( 44 | color: Colors.black.withAlpha(180), 45 | blurRadius: 10, 46 | spreadRadius: -10, 47 | offset: const Offset(5, 15)) 48 | ], 49 | ), 50 | child: ClipRRect( 51 | borderRadius: BorderRadius.circular(7), 52 | child: Image( 53 | image: imageProvider, 54 | ), 55 | )), 56 | placeholder: (context, url) => Container( 57 | decoration: BoxDecoration( 58 | color: Colors.black, borderRadius: BorderRadius.circular(5)), 59 | child: Icon( 60 | FontAwesomeIcons.image, 61 | color: AppColors.darkGray, 62 | size: 30, 63 | ), 64 | ), 65 | fit: BoxFit.fill, 66 | ), 67 | Container( 68 | height: 136, 69 | decoration: BoxDecoration( 70 | borderRadius: BorderRadius.circular(7), 71 | gradient: LinearGradient( 72 | begin: Alignment.topCenter, 73 | end: Alignment.bottomCenter, 74 | colors: [ 75 | Colors.transparent, 76 | AppColors.darkGray.withAlpha(50), 77 | AppColors.darkGray.withAlpha(230), 78 | 79 | // AppColors.darkGray.withAlpha(250), 80 | ])), 81 | ), 82 | Padding( 83 | padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 0), 84 | child: Column( 85 | crossAxisAlignment: CrossAxisAlignment.start, 86 | mainAxisAlignment: MainAxisAlignment.end, 87 | mainAxisSize: MainAxisSize.min, 88 | children: [ 89 | const SizedBox(height: 118), 90 | // Headline4( 91 | // item.title, 92 | // maxLines: 1, 93 | // overflow: TextOverflow.ellipsis, 94 | // style: const TextStyle(fontSize: 6), 95 | // ), 96 | Row( 97 | mainAxisAlignment: MainAxisAlignment.spaceBetween, 98 | crossAxisAlignment: CrossAxisAlignment.end, 99 | children: [ 100 | Subtitle1( 101 | item.year.toString(), 102 | style: TextStyle(color: AppColors.gray1, fontSize: 8), 103 | ), 104 | ref.watch(watchedProvider.notifier).items.contains( 105 | BaseHelper.hiveKey(item.type, item.ids.trakt)) || 106 | item.watched 107 | ? const Watched( 108 | iconOnly: true, 109 | ) 110 | : const SizedBox(), 111 | Row( 112 | children: [ 113 | Icon( 114 | FontAwesomeIcons.solidStar, 115 | size: 7, 116 | color: AppColors.primary, 117 | ), 118 | const SizedBox(width: 5), 119 | Subtitle2( 120 | item.roundedRating.toString(), 121 | style: const TextStyle(fontSize: 8), 122 | ), 123 | ], 124 | ), 125 | ], 126 | ) 127 | ], 128 | ), 129 | ), 130 | ], 131 | ); 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /lib/ui/cover/still_cover.dart: -------------------------------------------------------------------------------- 1 | import 'package:cached_network_image/cached_network_image.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:font_awesome_flutter/font_awesome_flutter.dart'; 4 | import 'package:helpers/helpers.dart'; 5 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 6 | import 'package:odin/data/entities/trakt.dart'; 7 | import 'package:odin/data/services/trakt_service.dart'; 8 | import 'package:odin/helpers.dart'; 9 | import 'package:odin/theme.dart'; 10 | import 'package:odin/ui/widgets/widgets.dart'; 11 | 12 | class StillCover extends HookConsumerWidget { 13 | final Trakt? item; 14 | final Trakt? season; 15 | final Trakt? show; 16 | final Function? onPressed; 17 | final Function? onFocus; 18 | final bool? focus; 19 | const StillCover( 20 | {Key? key, 21 | this.item, 22 | this.season, 23 | this.onPressed, 24 | this.show, 25 | this.onFocus, 26 | this.focus}) 27 | : super(key: key); 28 | 29 | String airDate(DateTime date) { 30 | return "${date.day.toString().padLeft(2, '0')}.${date.month.toString().padLeft(2, '0')}.${date.year}"; 31 | } 32 | 33 | bool isWatched(ref, Trakt item, Trakt season, Trakt show) { 34 | return item.watched || 35 | ref.watch(watchedProvider.notifier).items.contains(BaseHelper.hiveKey( 36 | 'show', show.ids.trakt, season.number, item.number)); 37 | } 38 | 39 | @override 40 | Widget build(BuildContext context, ref) { 41 | return GestureDetector( 42 | onTap: () { 43 | onPressed!(); 44 | }, 45 | child: Stack( 46 | children: [ 47 | CachedNetworkImage( 48 | imageBuilder: (context, imageProvider) => Container( 49 | decoration: BoxDecoration( 50 | boxShadow: [ 51 | BoxShadow( 52 | color: Colors.black.withAlpha(180), 53 | blurRadius: 10, 54 | spreadRadius: -10, 55 | offset: const Offset(5, 15)) 56 | ], 57 | ), 58 | child: ClipRRect( 59 | borderRadius: BorderRadius.circular(7), 60 | child: Image( 61 | image: imageProvider, 62 | ), 63 | )), 64 | imageUrl: item?.tmdb?.stillSmall ?? '', 65 | errorWidget: (_, __, ___) => Container( 66 | height: 105, 67 | width: 185, 68 | decoration: BoxDecoration( 69 | borderRadius: BorderRadius.circular(7), 70 | color: AppColors.darkGray, 71 | boxShadow: [ 72 | BoxShadow( 73 | color: Colors.black.withAlpha(180), 74 | blurRadius: 10, 75 | spreadRadius: -10, 76 | offset: const Offset(5, 15)) 77 | ], 78 | ), 79 | child: Icon( 80 | FontAwesomeIcons.image, 81 | color: AppColors.darkGray, 82 | size: 30, 83 | ), 84 | ), 85 | placeholder: (_, __) => Container( 86 | height: 105, 87 | width: 185, 88 | decoration: BoxDecoration( 89 | color: AppColors.darkGray, 90 | borderRadius: BorderRadius.circular(7), 91 | boxShadow: [ 92 | BoxShadow( 93 | color: Colors.black.withAlpha(180), 94 | blurRadius: 10, 95 | spreadRadius: -10, 96 | offset: const Offset(5, 15)) 97 | ], 98 | ), 99 | child: Icon( 100 | FontAwesomeIcons.image, 101 | color: AppColors.darkGray, 102 | size: 30, 103 | ), 104 | ), 105 | fit: BoxFit.fill, 106 | ), 107 | Container( 108 | height: 105, 109 | width: 185, 110 | decoration: BoxDecoration( 111 | borderRadius: BorderRadius.circular(7), 112 | gradient: LinearGradient( 113 | begin: Alignment.topCenter, 114 | end: Alignment.bottomCenter, 115 | colors: [ 116 | Colors.transparent, 117 | AppColors.darkGray.withAlpha(50), 118 | AppColors.darkGray.withAlpha(200), 119 | AppColors.darkGray.withAlpha(250), 120 | 121 | // AppColors.darkGray.withAlpha(250), 122 | ])), 123 | ), 124 | Container( 125 | height: 105, 126 | width: 185, 127 | padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), 128 | child: Column( 129 | crossAxisAlignment: CrossAxisAlignment.start, 130 | mainAxisAlignment: MainAxisAlignment.end, 131 | children: [ 132 | Row( 133 | mainAxisSize: MainAxisSize.max, 134 | mainAxisAlignment: MainAxisAlignment.spaceBetween, 135 | children: [ 136 | Headline4('S${season?.number}E${item?.number}', 137 | style: TextStyle( 138 | fontSize: 8, 139 | color: AppColors.gray1.withOpacity(0.5))), 140 | isWatched(ref, item!, season!, show!) 141 | ? const Watched(iconOnly: true) 142 | : const SizedBox(), 143 | CaptionText( 144 | item!.firstAired.isAfter(DateTime.now()) 145 | ? 'in ${item!.firstAired.difference(DateTime.now()).inDays} days' 146 | : airDate(item!.firstAired), 147 | style: TextStyle(fontSize: 6, color: AppColors.gray1), 148 | ), 149 | ], 150 | ), 151 | CaptionText( 152 | item?.title ?? '', 153 | overflow: TextOverflow.ellipsis, 154 | maxLines: 1, 155 | style: const TextStyle(fontSize: 9), 156 | ), 157 | ], 158 | ), 159 | ) 160 | ], 161 | ), 162 | ); 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /lib/ui/detail/detail.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 | import 'package:odin/data/entities/trakt.dart'; 4 | import 'package:odin/data/services/imdb_service.dart'; 5 | 6 | import 'detail_movie.dart'; 7 | import 'detail_show.dart'; 8 | 9 | class Detail extends ConsumerWidget { 10 | final Trakt item; 11 | const Detail({Key? key, required this.item}) : super(key: key); 12 | @override 13 | Widget build(BuildContext context, ref) { 14 | ref.watch(imdbProvider.notifier).getReviews(item.ids.imdb); 15 | // ref.read(detailController.notifier).onInit(item); 16 | return item.isMovie ? DetailMovie(item) : DetailShow(item); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /lib/ui/detail/detail_movie.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 | import 'package:font_awesome_flutter/font_awesome_flutter.dart'; 4 | import 'package:odin/ui/dialogs/default.dart'; 5 | import 'package:odin/ui/dialogs/streams.dart'; 6 | import 'package:odin/ui/widgets/ensure_visible.dart'; 7 | import 'package:odin/ui/widgets/widgets.dart'; 8 | import 'package:odin/data/entities/trakt.dart'; 9 | // import 'package:'; 10 | 11 | import 'widgets.dart'; 12 | 13 | class DetailMovie extends StatelessWidget { 14 | final Trakt item; 15 | const DetailMovie(this.item, {Key? key}) : super(key: key); 16 | 17 | @override 18 | Widget build(BuildContext context) { 19 | return Background( 20 | item, 21 | child: Column( 22 | mainAxisAlignment: MainAxisAlignment.end, 23 | children: [ 24 | SizedBox( 25 | child: Column( 26 | mainAxisAlignment: MainAxisAlignment.spaceBetween, 27 | children: [ 28 | Buttons(item: item), 29 | ItemDetails(item: item), 30 | ], 31 | ), 32 | ), 33 | const SizedBox(height: 20), 34 | ItemSlides(item: item), 35 | ], 36 | ), 37 | ); 38 | } 39 | } 40 | 41 | class Buttons extends ConsumerWidget { 42 | final Trakt item; 43 | const Buttons({Key? key, required this.item}) : super(key: key); 44 | 45 | @override 46 | Widget build(BuildContext context, ref) { 47 | return Padding( 48 | padding: const EdgeInsets.only(left: 50.0), 49 | child: SizedBox( 50 | height: 25, 51 | child: Row( 52 | children: [ 53 | DefaultButton( 54 | "Watch now", 55 | icon: FontAwesomeIcons.play, 56 | onPress: () { 57 | showDialog( 58 | context: context, 59 | builder: (context) => DefaultDialog( 60 | child: StreamsDialog(item: item), 61 | )); 62 | }, 63 | ), 64 | // ButtonWithIcon('Watch now', 65 | // icon: const Icon(FontAwesomeIcons.play, 66 | // size: 12, color: Colors.white), 67 | // node: controller.playButtonNode, onPress: () async { 68 | // showDialog( 69 | // context: context, 70 | // builder: (context) => DefaultDialog( 71 | // child: StreamsDialog(item: item), 72 | // )); 73 | // }), 74 | const SizedBox(width: 20), 75 | const HealthCheck() 76 | // ButtonWithIcon( 77 | // 'Trailer', 78 | // icon: const Icon(FontAwesomeIcons.youtube, 79 | // size: 15, color: Colors.white), 80 | // onPress: () { 81 | // controller.playTrailer(); 82 | // }, 83 | // ), 84 | // const SizedBox(width: 20), 85 | // ButtonWithIcon( 86 | // 'Trakt', 87 | // icon: Image.asset( 88 | // 'assets/images/trakt.png', 89 | // width: 15, 90 | // color: Colors.white, 91 | // ), 92 | // onPress: () {}, 93 | // ) 94 | ], 95 | ), 96 | ), 97 | ); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /lib/ui/detail/detail_show.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:font_awesome_flutter/font_awesome_flutter.dart'; 3 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 4 | import 'package:odin/controllers/app_controller.dart'; 5 | import 'package:odin/theme.dart'; 6 | import 'package:odin/data/entities/trakt.dart'; 7 | import 'package:odin/ui/widgets/widgets.dart'; 8 | 9 | import 'widgets.dart'; 10 | 11 | class DetailShow extends HookConsumerWidget { 12 | final Trakt item; 13 | const DetailShow(this.item, {Key? key}) : super(key: key); 14 | 15 | @override 16 | Widget build(BuildContext context, ref) { 17 | return Container( 18 | color: AppColors.darkGray, 19 | child: Background( 20 | item, 21 | child: Column( 22 | mainAxisAlignment: MainAxisAlignment.end, 23 | children: [ 24 | SizedBox( 25 | child: Column( 26 | mainAxisAlignment: MainAxisAlignment.spaceBetween, 27 | children: [ 28 | Buttons(item: item), 29 | // const SizedBox(height: 10), 30 | ItemDetails(item: item), 31 | ], 32 | ), 33 | ), 34 | const SizedBox(height: 20), 35 | ItemSlides(item: item), 36 | ], 37 | ), 38 | ), 39 | ); 40 | } 41 | } 42 | 43 | class Buttons extends ConsumerWidget { 44 | final Trakt item; 45 | const Buttons({Key? key, required this.item}) : super(key: key); 46 | 47 | @override 48 | Widget build(BuildContext context, ref) { 49 | final mf = ref.watch(beforeFocusProvider); 50 | return Padding( 51 | padding: const EdgeInsets.only(left: 40.0), 52 | child: SizedBox( 53 | height: 25, 54 | child: Row( 55 | children: [ 56 | ButtonWithIcon('Press DOWN arrow for seasons', 57 | icon: const Icon(FontAwesomeIcons.circleArrowDown, 58 | size: 15, color: Colors.white), 59 | node: FocusNode(canRequestFocus: mf, skipTraversal: !mf), 60 | onPress: () { 61 | // model.showEpisodes(); 62 | }), 63 | // const SizedBox(width: 20), 64 | // ButtonWithIcon( 65 | // 'Trailer', 66 | // node: FocusNode(canRequestFocus: mf, skipTraversal: !mf), 67 | // icon: const Icon(FontAwesomeIcons.youtube, 68 | // size: 15, color: Colors.white), 69 | // onPress: () { 70 | // // model.playTrailer(); 71 | // }, 72 | // ), 73 | // const SizedBox(width: 20), 74 | // ButtonWithIcon( 75 | // 'Trakt', 76 | // node: FocusNode(canRequestFocus: mf, skipTraversal: !mf), 77 | // icon: Image.asset( 78 | // 'assets/images/trakt.png', 79 | // width: 15, 80 | // color: Colors.white, 81 | // ), 82 | // onPress: () { 83 | // // show trakt Dialog 84 | // }, 85 | // ) 86 | const HealthCheck(), 87 | ], 88 | ), 89 | ), 90 | ); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /lib/ui/detail/episodes.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter/services.dart'; 3 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 4 | import 'package:helpers/helpers.dart'; 5 | import 'package:odin/controllers/detail_controller.dart'; 6 | 7 | import 'package:odin/data/entities/trakt.dart'; 8 | import 'package:odin/ui/cover/animated.dart'; 9 | import 'package:odin/ui/cover/still_cover.dart'; 10 | import 'package:odin/ui/dialogs/default.dart'; 11 | import 'package:odin/ui/dialogs/streams.dart'; 12 | import 'package:odin/ui/widgets/carousel.dart'; 13 | 14 | class Episodes extends ConsumerWidget { 15 | final Trakt season; 16 | final Trakt show; 17 | const Episodes({Key? key, required this.season, required this.show}) 18 | : super(key: key); 19 | 20 | String airDate(DateTime date) { 21 | return "${date.day.toString().padLeft(2, '0')}.${date.month.toString().padLeft(2, '0')}.${date.year}"; 22 | } 23 | 24 | @override 25 | Widget build(BuildContext context, ref) { 26 | ref.watch(detailController); 27 | const extent = 200.0; 28 | return Column( 29 | mainAxisAlignment: MainAxisAlignment.start, 30 | crossAxisAlignment: CrossAxisAlignment.start, 31 | children: [ 32 | Padding( 33 | padding: const EdgeInsets.only(left: 50), 34 | child: 35 | Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ 36 | Headline4( 37 | season.number == 0 ? season.title : 'Season ${season.number}', 38 | ), 39 | ]), 40 | ), 41 | SizedBox( 42 | height: 120, 43 | child: OdinCarousel( 44 | itemBuilder: (context, itemIndex, realIndex, controller) { 45 | return AnimatedCover( 46 | controller: controller, 47 | realIndex: realIndex, 48 | extent: extent, 49 | target: true, 50 | child: StillCover( 51 | item: season.episodes[itemIndex], 52 | season: season, 53 | show: show, 54 | onPressed: () { 55 | showDialog( 56 | context: context, 57 | builder: (context) => DefaultDialog( 58 | child: StreamsDialog( 59 | item: season.episodes[itemIndex], 60 | season: season, 61 | show: show), 62 | )); 63 | }, 64 | focus: itemIndex == 0), 65 | ); 66 | }, 67 | onRowIndexChanged: (index) {}, 68 | onChildIndexChanged: (index) {}, 69 | id: season.title, 70 | onEnter: (idx) { 71 | showDialog( 72 | context: context, 73 | builder: (context) => DefaultDialog( 74 | child: StreamsDialog( 75 | item: season.episodes[idx], 76 | season: season, 77 | show: show), 78 | )); 79 | }, 80 | extent: extent, 81 | anchor: 0.05, 82 | count: season.episodes.length, 83 | axis: Axis.horizontal)), 84 | const SizedBox(height: 20), 85 | ], 86 | ); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /lib/ui/dialogs/default.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 | import 'package:odin/theme.dart'; 4 | 5 | class DefaultDialog extends ConsumerWidget { 6 | final Widget child; 7 | final String title; 8 | const DefaultDialog({Key? key, required this.child, this.title = ''}) 9 | : super(key: key); 10 | 11 | @override 12 | Widget build(BuildContext context, WidgetRef ref) { 13 | return Dialog( 14 | backgroundColor: AppColors.darkGray, 15 | shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), 16 | child: child, 17 | ); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /lib/ui/focusnodes.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | final menufocus = [ 4 | FocusNode(canRequestFocus: false), 5 | FocusNode(canRequestFocus: false), 6 | FocusNode(canRequestFocus: false), 7 | FocusNode(canRequestFocus: false), 8 | ]; 9 | -------------------------------------------------------------------------------- /lib/ui/login.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:helpers/helpers.dart'; 3 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 4 | import 'package:odin/data/models/auth_model.dart'; 5 | import 'package:odin/ui/widgets/widgets.dart'; 6 | 7 | class Login extends HookConsumerWidget { 8 | const Login({Key? key}) : super(key: key); 9 | 10 | @override 11 | Widget build(BuildContext context, WidgetRef ref) { 12 | final code = ref.watch(codeProvider); 13 | final url = ref.watch(urlProvider); 14 | final error = ref.watch(errorProvider); 15 | return Scaffold( 16 | body: Center( 17 | child: Column( 18 | mainAxisSize: MainAxisSize.min, 19 | children: [ 20 | const OdinLogo(height: 100, showText: false), 21 | const SizedBox(height: 20), 22 | error != "" ? BodyText1(error) : const SizedBox(), 23 | url != "" 24 | ? BodyText1("Connecting to: $url") 25 | : const Opacity( 26 | opacity: 0.5, 27 | child: Column( 28 | mainAxisAlignment: MainAxisAlignment.start, 29 | crossAxisAlignment: CrossAxisAlignment.start, 30 | children: [ 31 | Headline4( 32 | 'Please use the ODIN frontend to link this device with your user account.'), 33 | BodyText1( 34 | "Devices -> Link Device -> Enter code -> Connect"), 35 | // BodyText1("- Go to Devices"), 36 | // BodyText1("- Click on 'Link Device'"), 37 | // BodyText1("- Enter the code below"), 38 | // BodyText1("- Click on 'Connect'"), 39 | ], 40 | ), 41 | ), 42 | const SizedBox(height: 20), 43 | BodyText1(code, style: const TextStyle(fontSize: 50)), 44 | ], 45 | )), 46 | ); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /lib/ui/pages/grid.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 | import 'package:odin/controllers/grid_controller.dart'; 4 | import 'package:odin/data/models/item_model.dart'; 5 | import 'package:odin/ui/widgets/carousel.dart'; 6 | import 'package:odin/ui/widgets/section.dart'; 7 | // import 'package:odintv/ui/cover/poster_cover.dart'; 8 | 9 | class Grid extends ConsumerWidget { 10 | final String type; 11 | const Grid(this.type, {Key? key}) : super(key: key); 12 | @override 13 | Widget build(BuildContext context, ref) { 14 | final provider = ref.watch(gridSectionProvider(type)); 15 | 16 | List sections = []; 17 | provider.whenData((value) { 18 | sections = value; 19 | if (value.isNotEmpty) { 20 | Future.delayed(const Duration(milliseconds: 10), () { 21 | ref.read(currentRow.notifier).state = sections[0].url; 22 | // ref.read(bgAlpha.notifier).state = 230; 23 | // ref.read(selectedSectionProvider.notifier).state = value[0].title; 24 | }); 25 | } 26 | }); 27 | 28 | double extent = 185; 29 | 30 | return sections.isEmpty 31 | ? const SizedBox() 32 | : Padding( 33 | padding: const EdgeInsets.only(top: 150), 34 | child: OdinCarousel( 35 | itemBuilder: (context, itemIndex, realIndex, controller) { 36 | return Section(e: sections[itemIndex], idx: itemIndex); 37 | }, 38 | extent: extent, 39 | onRowIndexChanged: (index) { 40 | Future.delayed(const Duration(milliseconds: 50), () { 41 | ref.read(currentRow.notifier).state = sections[index].url; 42 | 43 | // ref.read(selectedSectionProvider.notifier).state = 44 | // sections[index].url; 45 | // ref.read(currentRow.notifier).state = sections[index].url; 46 | // ref.read(selectedItemProvider.notifier).state = ref.read( 47 | // selectedItemOfSectionProvider(sections[index].title)); 48 | }); 49 | }, 50 | onChildIndexChanged: (index) { 51 | // Future.delayed(const Duration(milliseconds: 50), () { 52 | // ref.read(selectedSectionProvider.notifier).state = 53 | // sections[index].title; 54 | // ref.read(currentRow.notifier).state = sections[index].url; 55 | // ref.read(selectedItemProvider.notifier).state = ref.read( 56 | // selectedItemOfSectionProvider(sections[index].title)); 57 | // }); 58 | }, 59 | anchor: 0.0, 60 | count: sections.length, 61 | axis: Axis.vertical)); 62 | } 63 | } 64 | 65 | // need two separate classes for fadethrough-animation to work propperly 66 | 67 | class MoviesGrid extends StatelessWidget { 68 | const MoviesGrid({Key? key}) : super(key: key); 69 | 70 | @override 71 | Widget build(BuildContext context) { 72 | return const Grid('movies'); 73 | } 74 | } 75 | 76 | class ShowsGrid extends StatelessWidget { 77 | const ShowsGrid({Key? key}) : super(key: key); 78 | 79 | @override 80 | Widget build(BuildContext context) { 81 | return const Grid('shows'); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /lib/ui/pages/home.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter/services.dart'; 3 | import 'package:flutter_hooks/flutter_hooks.dart'; 4 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 5 | import 'package:odin/controllers/app_controller.dart'; 6 | import 'package:odin/controllers/home_controller.dart'; 7 | import 'package:odin/data/models/item_model.dart'; 8 | import 'package:odin/helpers.dart'; 9 | import 'package:odin/ui/focusnodes.dart'; 10 | import 'package:odin/ui/widgets/carousel.dart'; 11 | import 'package:odin/ui/widgets/section.dart'; 12 | 13 | class HomePage extends HookConsumerWidget with BaseHelper { 14 | const HomePage({Key? key}) : super(key: key); 15 | 16 | toggleMenuFocus(bool focus) { 17 | for (var i = 0; i < menufocus.length; i++) { 18 | menufocus[i].canRequestFocus = focus; 19 | } 20 | } 21 | 22 | @override 23 | Widget build(BuildContext context, ref) { 24 | final provider = ref.watch(homeSectionProvider); 25 | 26 | List sections = []; 27 | provider.whenData((value) { 28 | sections = value; 29 | if (value.isNotEmpty) { 30 | Future.delayed(const Duration(milliseconds: 10), () { 31 | ref.read(currentRow.notifier).state = sections[0].url; 32 | // ref.read(selectedSectionProvider.notifier).state = value[0].title; 33 | // ref.read(bgAlpha.notifier).state = 50; 34 | }); 35 | } 36 | }); 37 | 38 | double extent = 185; 39 | return sections.isEmpty 40 | ? SizedBox(height: extent) 41 | : Container( 42 | padding: const EdgeInsets.only(top: 200), 43 | height: extent * sections.length, 44 | child: OdinCarousel( 45 | itemBuilder: (context, itemIndex, realIndex, controller) { 46 | return Section(e: sections[itemIndex], idx: itemIndex); 47 | }, 48 | extent: extent, 49 | onRowIndexChanged: (index) { 50 | Future.delayed(const Duration(milliseconds: 50), () { 51 | // final data = useMemoized(() => ItemsProviderData( 52 | // sections[index].url, sections[index].filterWatched)); 53 | ref.read(currentRow.notifier).state = sections[index].url; 54 | 55 | // ref.read(selectedItemProvider.notifier).state = 56 | // ref.read(itemsProvider(data))[0]; 57 | 58 | ref.read(selectedSectionProvider.notifier).state = 59 | sections[index].title; 60 | }); 61 | }, 62 | onChildIndexChanged: (index) {}, 63 | anchor: 0.0, 64 | count: sections.length, 65 | axis: Axis.vertical)); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /lib/ui/selectbuttonfixer.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_hooks/flutter_hooks.dart'; 3 | import 'package:font_awesome_flutter/font_awesome_flutter.dart'; 4 | import 'package:helpers/helpers.dart'; 5 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 6 | import 'package:odin/data/models/auth_model.dart'; 7 | import 'package:odin/main.dart'; 8 | import 'package:odin/theme.dart'; 9 | import 'package:odin/ui/widgets/widgets.dart'; 10 | 11 | class SelectButtonFixer extends HookConsumerWidget { 12 | const SelectButtonFixer({Key? key}) : super(key: key); 13 | 14 | @override 15 | Widget build(BuildContext context, WidgetRef ref) { 16 | final current = useState(""); 17 | final fn = useFocusNode(); 18 | fn.requestFocus(); 19 | return Scaffold( 20 | body: Center( 21 | child: Column( 22 | mainAxisSize: MainAxisSize.min, 23 | children: [ 24 | const Headline4("Fix select button"), 25 | const CaptionText( 26 | "Due to a bug in Flutter, Odin needs to detect your remotes select key (usually the middle button) manually."), 27 | const SizedBox(height: 20), 28 | const BodyText1("Please click the middle key on your remote now!"), 29 | const SizedBox(height: 20), 30 | KeyboardListener( 31 | focusNode: fn, 32 | onKeyEvent: (KeyEvent keyEvent) async { 33 | final label = keyEvent.logicalKey.keyLabel; 34 | if (label == "Select" || 35 | label.toString().contains("Key with ID")) { 36 | current.value = "${keyEvent.physicalKey.debugName}"; 37 | ref.read(selectButtonProvider.notifier).state = 38 | keyEvent.physicalKey.usbHidUsage; 39 | } else { 40 | current.value = "${label}"; 41 | } 42 | }, 43 | child: Icon(FontAwesomeIcons.circleDot, 44 | color: AppColors.primary, size: 100), 45 | ), 46 | BodyText1(current.value) 47 | ], 48 | )), 49 | ); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /lib/ui/userselect.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:font_awesome_flutter/font_awesome_flutter.dart'; 3 | import 'package:helpers/helpers.dart'; 4 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 5 | import 'package:odin/data/models/auth_model.dart'; 6 | import 'package:odin/ui/widgets/widgets.dart'; 7 | 8 | class UserSelect extends HookConsumerWidget { 9 | const UserSelect({Key? key}) : super(key: key); 10 | 11 | @override 12 | Widget build(BuildContext context, ref) { 13 | final users = []; 14 | ref.watch(usersProvider); 15 | final usersData = ref.refresh(usersProvider); 16 | // ref.refresh(usersProvider); 17 | usersData.whenData((data) => {users.addAll(data)}); 18 | return Scaffold( 19 | body: Center( 20 | child: Column( 21 | mainAxisSize: MainAxisSize.min, 22 | children: [ 23 | const OdinLogo(height: 100, showText: false), 24 | const SizedBox(height: 20), 25 | const Headline4("Select a user"), 26 | const SizedBox(height: 20), 27 | Row( 28 | mainAxisAlignment: MainAxisAlignment.spaceEvenly, 29 | children: List.from( 30 | users.map( 31 | (u) => DefaultButton( 32 | "${u.user["username"]}", 33 | icon: FontAwesomeIcons.user, 34 | onPress: () { 35 | ref.read(authProvider.notifier).selectUser(u); 36 | }, 37 | ), 38 | ), 39 | )..add( 40 | DefaultButton( 41 | "New user", 42 | icon: FontAwesomeIcons.userPlus, 43 | onPress: () { 44 | ref.read(authProvider.notifier).newUser(); 45 | }, 46 | ), 47 | )), 48 | ], 49 | )), 50 | ); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /lib/ui/widgets/buttons.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:helpers/helpers.dart'; 3 | 4 | class DefaultButton extends StatelessWidget { 5 | final String text; 6 | final Color color; 7 | final IconData? icon; 8 | final Function? onPress; 9 | const DefaultButton(this.text, 10 | {Key? key, this.color = Colors.white, this.onPress, this.icon}) 11 | : super(key: key); 12 | 13 | @override 14 | Widget build(BuildContext context) { 15 | return MaterialButton( 16 | onPressed: () { 17 | if (onPress != null) { 18 | onPress!(); 19 | } 20 | }, 21 | child: icon != null 22 | ? Row(children: [ 23 | Icon(icon, color: Colors.white, size: 12), 24 | const SizedBox(width: 5), 25 | BodyText1(text), 26 | ]) 27 | : BodyText1(text)); 28 | } 29 | } 30 | 31 | class ButtonWithIcon extends StatelessWidget { 32 | final Function? onPress; 33 | final FocusNode? node; 34 | final String? text; 35 | final Widget? icon; 36 | const ButtonWithIcon(this.text, 37 | {Key? key, this.icon, required this.onPress, this.node}) 38 | : super(key: key); 39 | @override 40 | Widget build(BuildContext context) { 41 | return TextButton( 42 | focusNode: node, 43 | onPressed: () { 44 | if (onPress != null) { 45 | onPress!(); 46 | } 47 | }, 48 | child: Row( 49 | children: [ 50 | icon ?? const SizedBox(), 51 | const SizedBox(width: 10), 52 | CaptionText(text ?? '') 53 | ], 54 | )); 55 | } 56 | } 57 | 58 | // class RoundIconButton extends StatelessWidget { 59 | // final double size; 60 | // final Function onPressed; 61 | // final IconData icon; 62 | // RoundIconButton({this.onPressed, this.icon, this.size: 35.0}); 63 | // @override 64 | // Widget build(BuildContext context) { 65 | // return ClipRRect( 66 | // borderRadius: BorderRadius.circular(size), 67 | // child: Container( 68 | // width: size, 69 | // height: size, 70 | // child: FlatButton( 71 | // padding: EdgeInsets.all(0), 72 | // color: Colors.black.withAlpha(100), 73 | // onPressed: () { 74 | // if (onPressed != null) { 75 | // onPressed(); 76 | // } 77 | // }, 78 | // child: Icon( 79 | // icon, 80 | // color: Colors.white, 81 | // size: size / 2.5, 82 | // )), 83 | // ), 84 | // ); 85 | // } 86 | // } 87 | 88 | // class FakeButtonForScroll extends StatelessWidget { 89 | // @override 90 | // Widget build(BuildContext context) { 91 | // return Container( 92 | // height: 0, 93 | // child: Opacity( 94 | // opacity: 0, 95 | // child: RawMaterialButton(onPressed: () {}, child: Text('Fake')), 96 | // ), 97 | // ); 98 | // } 99 | // } 100 | -------------------------------------------------------------------------------- /lib/ui/widgets/ensure_visible.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter/services.dart'; 3 | 4 | class EnsureVisible extends StatefulWidget { 5 | final Widget child; 6 | final double alignment; 7 | final bool isLast; 8 | final bool isFirst; 9 | final double paddingTop; 10 | final Function? onFocus; 11 | final Function? onRight; 12 | final Function? onLeft; 13 | const EnsureVisible( 14 | {Key? key, 15 | required this.child, 16 | this.alignment = 0.1, 17 | this.paddingTop = 20, 18 | this.isLast = false, 19 | this.isFirst = false, 20 | this.onFocus, 21 | this.onRight, 22 | this.onLeft}) 23 | : super(key: key); 24 | 25 | @override 26 | State createState() => _EnsureVisibleState(); 27 | } 28 | 29 | class _EnsureVisibleState extends State { 30 | @override 31 | Widget build(BuildContext context) => Focus( 32 | canRequestFocus: false, 33 | onFocusChange: (focused) { 34 | if (focused) { 35 | Scrollable.ensureVisible( 36 | context, 37 | alignment: widget.alignment, 38 | duration: const Duration(milliseconds: 300), 39 | curve: Curves.easeInOut, 40 | ); 41 | if (widget.onFocus != null) { 42 | widget.onFocus!(); 43 | } 44 | } 45 | }, 46 | child: Container( 47 | padding: EdgeInsets.only(top: widget.paddingTop), 48 | child: widget.child), 49 | ); 50 | } 51 | 52 | class EnsureVisibleList extends StatefulWidget { 53 | final Widget child; 54 | final double alignment; 55 | final bool isLast; 56 | final bool isFirst; 57 | final double paddingTop; 58 | final Function? onFocus; 59 | const EnsureVisibleList( 60 | {Key? key, 61 | required this.child, 62 | this.alignment = 0.1, 63 | this.paddingTop = 20, 64 | this.isLast = false, 65 | this.isFirst = false, 66 | this.onFocus}) 67 | : super(key: key); 68 | 69 | @override 70 | State createState() => _EnsureVisibleListState(); 71 | } 72 | 73 | class _EnsureVisibleListState extends State { 74 | KeyEventResult _handleKey(BuildContext context, KeyEvent rawKeyEvent) { 75 | if (rawKeyEvent.logicalKey == LogicalKeyboardKey.arrowRight && 76 | widget.isLast) { 77 | return KeyEventResult.handled; 78 | } 79 | if (rawKeyEvent.logicalKey == LogicalKeyboardKey.arrowLeft && 80 | widget.isFirst) { 81 | return KeyEventResult.handled; 82 | } 83 | return KeyEventResult.ignored; 84 | } 85 | 86 | @override 87 | Widget build(BuildContext context) => Focus( 88 | onKeyEvent: (_, rawKeyEvent) => _handleKey(context, rawKeyEvent), 89 | canRequestFocus: false, 90 | onFocusChange: (focused) { 91 | if (focused) { 92 | Scrollable.ensureVisible( 93 | context, 94 | alignment: widget.alignment, 95 | duration: const Duration(milliseconds: 500), 96 | curve: Curves.easeInOut, 97 | ); 98 | if (widget.onFocus != null) { 99 | widget.onFocus!(); 100 | } 101 | } 102 | }, 103 | child: Container( 104 | padding: EdgeInsets.only(top: widget.paddingTop), 105 | child: widget.child), 106 | ); 107 | } 108 | -------------------------------------------------------------------------------- /lib/ui/widgets/loaders.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class PosterLoader extends StatelessWidget { 4 | const PosterLoader({Key? key}) : super(key: key); 5 | 6 | @override 7 | Widget build(BuildContext context) { 8 | return Container(); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /lib/ui/widgets/section.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter/services.dart'; 3 | import 'package:flutter_hooks/flutter_hooks.dart'; 4 | import 'package:helpers/helpers.dart'; 5 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 6 | import 'package:odin/controllers/app_controller.dart'; 7 | import 'package:odin/data/models/item_model.dart'; 8 | import 'package:odin/helpers.dart'; 9 | import 'package:odin/theme.dart'; 10 | import 'package:odin/ui/cover/animated.dart'; 11 | import 'package:odin/ui/cover/backdrop_cover.dart'; 12 | import 'package:odin/ui/cover/poster_cover.dart'; 13 | import 'package:odin/ui/detail/detail.dart'; 14 | import 'package:odin/ui/widgets/carousel.dart'; 15 | 16 | import '../../data/entities/trakt.dart'; 17 | 18 | class Section extends HookConsumerWidget with BaseHelper { 19 | final int idx; 20 | final SectionItem e; 21 | final Function? lastItemReached; 22 | const Section( 23 | {Key? key, required this.e, this.idx = -1, this.lastItemReached}) 24 | : super(key: key); 25 | 26 | @override 27 | Widget build(BuildContext context, ref) { 28 | final itemData = 29 | useMemoized(() => ItemsProviderData(e.url, e.filterWatched)); 30 | List items = ref.watch(itemsProvider(itemData)); 31 | 32 | double extent = e.big ? 225 : 90; 33 | //ref.watch(selectedItemProvider); 34 | final sec = ref.watch(currentRow); 35 | // final af = ref.watch(afterFocusProvider); 36 | // final bf = ref.watch(beforeFocusProvider); 37 | return Column( 38 | mainAxisSize: MainAxisSize.min, 39 | crossAxisAlignment: CrossAxisAlignment.start, 40 | mainAxisAlignment: MainAxisAlignment.start, 41 | children: [ 42 | Container( 43 | padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 0), 44 | alignment: Alignment.topLeft, 45 | child: Headline4( 46 | e.title.toCapitalize(), 47 | textAlign: TextAlign.left, 48 | style: TextStyle(color: AppColors.gray1, fontSize: 10), 49 | )), 50 | const SizedBox(height: 5), 51 | items.isEmpty 52 | ? SizedBox( 53 | height: 160, 54 | child: Padding( 55 | padding: const EdgeInsets.only(left: 20), 56 | child: CaptionText( 57 | 'Getting items...', 58 | style: TextStyle(color: AppColors.gray2), 59 | ), 60 | ), 61 | ) 62 | : SizedBox( 63 | height: 160, 64 | // width: double.infinity, 65 | child: OdinCarousel( 66 | itemBuilder: (context, itemIndex, realIndex, controller) { 67 | return AnimatedCover( 68 | controller: controller, 69 | extent: extent, 70 | realIndex: realIndex, 71 | target: sec == e.url && !ref.watch(beforeFocusHasFocus), 72 | child: e.big 73 | ? BackdropCover(items[itemIndex]) 74 | : PosterCover(items[itemIndex]), 75 | ); 76 | }, 77 | extent: extent, 78 | id: e.url, 79 | ensureVisible: true, 80 | alignment: e.big ? 0.16 : 0.10, 81 | onRowIndexChanged: (index) {}, 82 | onChildIndexChanged: (index) { 83 | Future.delayed(const Duration(milliseconds: 10), () { 84 | final items = ref.read(itemsProvider(itemData)); 85 | ref.read(selectedItemProvider.notifier).state = 86 | items[index].show ?? items[index]; 87 | // ref 88 | // .read( 89 | // selectedItemOfSectionProvider(e.title).notifier) 90 | // .state = items[index]; 91 | if (items.isNotEmpty && 92 | index == items.length - 1 && 93 | e.paginate) { 94 | ref.read(itemsProvider(itemData).notifier).next(); 95 | // ref.read(pageProvider(e.url).notifier).state++; 96 | } 97 | }); 98 | }, 99 | onEnter: (idx) async { 100 | final items = ref.read(itemsProvider(itemData)); 101 | Future.delayed(const Duration(milliseconds: 50), () { 102 | ref.read(currentRowBeforeDetail.notifier).state = 103 | ref.read(currentRow); 104 | }); 105 | await Navigator.of(context).push(MaterialPageRoute( 106 | builder: (context) => Detail( 107 | item: items[idx].type == "episode" 108 | ? items[idx].show! 109 | : items[idx]), 110 | )); 111 | // 112 | Future.delayed(const Duration(milliseconds: 50), () { 113 | ref.read(currentRow.notifier).state = 114 | ref.read(currentRowBeforeDetail); 115 | }); 116 | }, 117 | count: items.length, 118 | axis: Axis.horizontal), 119 | ) 120 | ], 121 | ); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /lib/ui/widgets/widgets.dart: -------------------------------------------------------------------------------- 1 | export 'buttons.dart'; 2 | export 'loaders.dart'; 3 | 4 | import 'package:flutter/material.dart'; 5 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 6 | import 'package:flutter_svg/flutter_svg.dart'; 7 | import 'package:font_awesome_flutter/font_awesome_flutter.dart'; 8 | import 'package:helpers/helpers/widgets/text.dart'; 9 | import 'package:odin/data/services/api.dart'; 10 | import 'package:odin/theme.dart'; 11 | 12 | class TextChip extends StatelessWidget { 13 | final String text; 14 | 15 | const TextChip(this.text, {Key? key}) : super(key: key); 16 | @override 17 | Widget build(BuildContext context) { 18 | return Container( 19 | padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 3), 20 | decoration: BoxDecoration( 21 | color: Colors.white.withAlpha(50), 22 | borderRadius: BorderRadius.circular(5)), 23 | child: BodyText1(text), 24 | ); 25 | } 26 | } 27 | 28 | class TextChipBig extends StatelessWidget { 29 | final String text; 30 | 31 | const TextChipBig(this.text, {Key? key}) : super(key: key); 32 | @override 33 | Widget build(BuildContext context) { 34 | return Container( 35 | padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 3), 36 | decoration: BoxDecoration( 37 | color: Colors.white.withAlpha(50), 38 | borderRadius: BorderRadius.circular(5)), 39 | child: Text( 40 | text, 41 | style: Theme.of(context).textTheme.headlineMedium, 42 | ), 43 | ); 44 | } 45 | } 46 | 47 | class HealthCheck extends ConsumerWidget { 48 | const HealthCheck({Key? key}) : super(key: key); 49 | 50 | @override 51 | Widget build(BuildContext context, ref) { 52 | final healthy = ref.watch(healthProvider); 53 | 54 | return healthy.when( 55 | data: (value) => value 56 | ? const SizedBox() 57 | : Container( 58 | color: AppColors.red, 59 | padding: const EdgeInsets.symmetric(vertical: 2.0, horizontal: 5), 60 | child: Row( 61 | crossAxisAlignment: CrossAxisAlignment.center, 62 | children: [ 63 | FaIcon(FontAwesomeIcons.triangleExclamation, 64 | size: 8, color: AppColors.darkGray), 65 | const SizedBox(width: 5), 66 | CaptionText("Connection error", 67 | style: TextStyle(color: AppColors.darkGray, fontSize: 8)), 68 | ], 69 | ), 70 | ), 71 | error: (_, __) => const BodyText1("Connection error"), 72 | loading: () => const SizedBox(), 73 | ); 74 | } 75 | } 76 | 77 | class OdinLogo extends StatelessWidget { 78 | final double height; 79 | final bool showText; 80 | const OdinLogo({Key? key, this.height = 25, this.showText = true}) 81 | : super(key: key); 82 | 83 | @override 84 | Widget build(BuildContext context) { 85 | return Row( 86 | mainAxisSize: MainAxisSize.min, 87 | children: [ 88 | SvgPicture.asset("assets/images/logo.svg", height: height), 89 | showText ? SizedBox(width: height / 5) : const SizedBox(), 90 | showText 91 | ? Headline4( 92 | "ODIN", 93 | style: TextStyle(fontSize: height / 1.5), 94 | ) 95 | : const SizedBox(), 96 | ], 97 | ); 98 | } 99 | } 100 | 101 | class Watched extends StatelessWidget { 102 | final bool iconOnly; 103 | const Watched({Key? key, this.iconOnly = false}) : super(key: key); 104 | 105 | @override 106 | Widget build(BuildContext context) { 107 | return Row( 108 | mainAxisSize: MainAxisSize.min, 109 | // ignore: prefer_const_literals_to_create_immutables 110 | children: [ 111 | Icon( 112 | FontAwesomeIcons.solidCircleCheck, 113 | color: AppColors.green.withAlpha(100), 114 | size: 9, 115 | ), 116 | SizedBox(width: iconOnly ? 0 : 5), 117 | iconOnly 118 | ? const SizedBox() 119 | : Subtitle1("Watched", style: TextStyle(color: AppColors.green)) 120 | ], 121 | ); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: odin 2 | description: A new Flutter project. 3 | 4 | # The following line prevents the package from being accidentally published to 5 | # pub.dev using `flutter pub publish`. This is preferred for private packages. 6 | publish_to: "none" # Remove this line if you wish to publish to pub.dev 7 | 8 | # The following defines the version and build number for your application. 9 | # A version number is three numbers separated by dots, like 1.2.43 10 | # followed by an optional build number separated by a +. 11 | # Both the version and the builder number may be overridden in flutter 12 | # build by specifying --build-name and --build-number, respectively. 13 | # In Android, build-name is used as versionName while build-number used as versionCode. 14 | # Read more about Android versioning at https://developer.android.com/studio/publish/versioning 15 | # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. 16 | # Read more about iOS versioning at 17 | # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html 18 | version: 1.0.0+1 19 | 20 | environment: 21 | sdk: ">=2.15.1 <3.0.0" 22 | 23 | # Dependencies specify other packages that your package needs in order to work. 24 | # To automatically upgrade your package dependencies to the latest versions 25 | # consider running `flutter pub upgrade --major-versions`. Alternatively, 26 | # dependencies can be manually updated by changing the version numbers below to 27 | # the latest version available on pub.dev. To see which dependencies have newer 28 | # versions available, run `flutter pub outdated`. 29 | dependencies: 30 | flutter: 31 | sdk: flutter 32 | android_intent_plus: 33 | animations: 34 | flutter_animate: 35 | infinite_carousel: 36 | carousel_slider: 37 | cached_network_image: 38 | dio: 39 | mqtt_client: 40 | json_annotation: 41 | google_fonts: 42 | device_info_plus: 43 | hive_ce: 44 | hive_ce_flutter: 45 | html_unescape: 46 | intl: 47 | mime: 48 | # path_provider: 49 | font_awesome_flutter: 50 | helpers: 51 | git: 52 | url: https://github.com/mitch2na/helpers.git 53 | ref: main 54 | localstorage: 55 | logging: 56 | flutter_riverpod: 57 | flutter_hooks: 0.20.5 58 | hooks_riverpod: 59 | uuid: 60 | web_socket_channel: 61 | fpdart: 62 | flutter_svg: 63 | 64 | # The following adds the Cupertino Icons font to your application. 65 | # Use with the CupertinoIcons class for iOS style icons. 66 | cupertino_icons: ^1.0.2 67 | 68 | dependency_overrides: 69 | win32: ^5.5.4 70 | 71 | dev_dependencies: 72 | flutter_test: 73 | sdk: flutter 74 | build_runner: 75 | freezed: 76 | json_serializable: 77 | 78 | # The "flutter_lints" package below contains a set of recommended lints to 79 | # encourage good coding practices. The lint set provided by the package is 80 | # activated in the `analysis_options.yaml` file located at the root of your 81 | # package. See that file for information about deactivating specific lint 82 | # rules and activating additional ones. 83 | flutter_lints: ^2.0.1 84 | 85 | # For information on the generic Dart part of this file, see the 86 | # following page: https://dart.dev/tools/pub/pubspec 87 | 88 | # The following section is specific to Flutter. 89 | flutter: 90 | # The following line ensures that the Material Icons font is 91 | # included with your application, so that you can use the icons in 92 | # the material Icons class. 93 | uses-material-design: true 94 | assets: 95 | - assets/images/ 96 | 97 | # To add assets to your application, add an assets section, like this: 98 | # assets: 99 | # - images/a_dot_burr.jpeg 100 | # - images/a_dot_ham.jpeg 101 | 102 | # An image asset can refer to one or more resolution-specific "variants", see 103 | # https://flutter.dev/assets-and-images/#resolution-aware. 104 | 105 | # For details regarding adding assets from package dependencies, see 106 | # https://flutter.dev/assets-and-images/#from-packages 107 | 108 | # To add custom fonts to your application, add a fonts section here, 109 | # in this "flutter" section. Each entry in this list should have a 110 | # "family" key with the font family name, and a "fonts" key with a 111 | # list giving the asset and other descriptors for the font. For 112 | # example: 113 | # fonts: 114 | # - family: Schyler 115 | # fonts: 116 | # - asset: fonts/Schyler-Regular.ttf 117 | # - asset: fonts/Schyler-Italic.ttf 118 | # style: italic 119 | # - family: Trajan Pro 120 | # fonts: 121 | # - asset: fonts/TrajanPro.ttf 122 | # - asset: fonts/TrajanPro_Bold.ttf 123 | # weight: 700 124 | # 125 | # For details regarding fonts from package dependencies, 126 | # see https://flutter.dev/custom-fonts/#from-packages 127 | -------------------------------------------------------------------------------- /screenshots/btc_donation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-on-is/odin-tv/2a84a609370b8cccdbd9ab3e0f578f63f4fa15f6/screenshots/btc_donation.png -------------------------------------------------------------------------------- /screenshots/connect.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-on-is/odin-tv/2a84a609370b8cccdbd9ab3e0f578f63f4fa15f6/screenshots/connect.png -------------------------------------------------------------------------------- /screenshots/odin-tv.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-on-is/odin-tv/2a84a609370b8cccdbd9ab3e0f578f63f4fa15f6/screenshots/odin-tv.png -------------------------------------------------------------------------------- /screenshots/odin-tv2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-on-is/odin-tv/2a84a609370b8cccdbd9ab3e0f578f63f4fa15f6/screenshots/odin-tv2.png -------------------------------------------------------------------------------- /test/widget_test.dart: -------------------------------------------------------------------------------- 1 | // This is a basic Flutter widget test. 2 | // 3 | // To perform an interaction with a widget in your test, use the WidgetTester 4 | // utility that Flutter provides. For example, you can send tap and scroll 5 | // gestures. You can also use WidgetTester to find child widgets in the widget 6 | // tree, read text, and verify that the values of widget properties are correct. 7 | 8 | import 'package:flutter/material.dart'; 9 | import 'package:flutter_test/flutter_test.dart'; 10 | 11 | import 'package:odin/main.dart'; 12 | 13 | void main() { 14 | testWidgets('Counter increments smoke test', (WidgetTester tester) async { 15 | // Build our app and trigger a frame. 16 | await tester.pumpWidget(const MyApp()); 17 | 18 | // Verify that our counter starts at 0. 19 | expect(find.text('0'), findsOneWidget); 20 | expect(find.text('1'), findsNothing); 21 | 22 | // Tap the '+' icon and trigger a frame. 23 | await tester.tap(find.byIcon(Icons.add)); 24 | await tester.pump(); 25 | 26 | // Verify that our counter has incremented. 27 | expect(find.text('0'), findsNothing); 28 | expect(find.text('1'), findsOneWidget); 29 | }); 30 | } 31 | -------------------------------------------------------------------------------- /test/widget_test.reflectable.dart: -------------------------------------------------------------------------------- 1 | // No output from reflectable, 'package:reflectable/reflectable.dart' not used. -------------------------------------------------------------------------------- /web/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-on-is/odin-tv/2a84a609370b8cccdbd9ab3e0f578f63f4fa15f6/web/favicon.png -------------------------------------------------------------------------------- /web/icons/Icon-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-on-is/odin-tv/2a84a609370b8cccdbd9ab3e0f578f63f4fa15f6/web/icons/Icon-192.png -------------------------------------------------------------------------------- /web/icons/Icon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-on-is/odin-tv/2a84a609370b8cccdbd9ab3e0f578f63f4fa15f6/web/icons/Icon-512.png -------------------------------------------------------------------------------- /web/icons/Icon-maskable-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-on-is/odin-tv/2a84a609370b8cccdbd9ab3e0f578f63f4fa15f6/web/icons/Icon-maskable-192.png -------------------------------------------------------------------------------- /web/icons/Icon-maskable-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-on-is/odin-tv/2a84a609370b8cccdbd9ab3e0f578f63f4fa15f6/web/icons/Icon-maskable-512.png -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | odin 33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /web/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "odin", 3 | "short_name": "odin", 4 | "start_url": ".", 5 | "display": "standalone", 6 | "background_color": "#0175C2", 7 | "theme_color": "#0175C2", 8 | "description": "A new Flutter project.", 9 | "orientation": "portrait-primary", 10 | "prefer_related_applications": false, 11 | "icons": [ 12 | { 13 | "src": "icons/Icon-192.png", 14 | "sizes": "192x192", 15 | "type": "image/png" 16 | }, 17 | { 18 | "src": "icons/Icon-512.png", 19 | "sizes": "512x512", 20 | "type": "image/png" 21 | }, 22 | { 23 | "src": "icons/Icon-maskable-192.png", 24 | "sizes": "192x192", 25 | "type": "image/png", 26 | "purpose": "maskable" 27 | }, 28 | { 29 | "src": "icons/Icon-maskable-512.png", 30 | "sizes": "512x512", 31 | "type": "image/png", 32 | "purpose": "maskable" 33 | } 34 | ] 35 | } 36 | --------------------------------------------------------------------------------