diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index b103f9b56..4bb39afdb 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,2 +1,2 @@ patreon: LibreTubeTeam -custom: https://libre-tube.github.io#donate +custom: https://github.com/libre-tube/LibreTube#-donate diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..3ba13e0ce --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/README.md b/README.md index 9cb63c092..4a2941890 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,29 @@
- LibreTube + LibreTube -[![GPL-v3](https://libre-tube.github.io/assets/license-widget.svg)](https://www.gnu.org/licenses/gpl-3.0.en.html) -[![Matrix](https://libre-tube.github.io/assets/mat-widget.svg)](https://matrix.to/#/#LibreTube:matrix.org) -[![Telegram](https://libre-tube.github.io/assets/tg-widget.svg)](https://t.me/libretube) -[![Twitter](https://libre-tube.github.io/assets/tw-widget.svg)](https://twitter.com/libretube) -[![Reddit](https://libre-tube.github.io/assets/rd-widget.svg)](https://www.reddit.com/r/Libretube/) +[![GPL-v3](https://libre-tube.github.io/images/license-widget.svg)](https://www.gnu.org/licenses/gpl-3.0.en.html) +[![Matrix](https://libre-tube.github.io/images/mat-widget.svg)](https://matrix.to/#/#LibreTube:matrix.org) +[![Telegram](https://libre-tube.github.io/images/tg-widget.svg)](https://t.me/libretube) +[![Twitter](https://libre-tube.github.io/images/tw-widget.svg)](https://twitter.com/libretube) +[![Reddit](https://libre-tube.github.io/images/rd-widget.svg)](https://www.reddit.com/r/Libretube/)
-[Get it on F-Droid](https://f-droid.org/en/packages/com.github.libretube/) -[Get it on IzzyOnDroid](https://apt.izzysoft.de/fdroid/index/apk/com.github.libretube)
-[Get it on GitHub](https://github.com/libre-tube/LibreTube/releases/latest) -[Get it on GitHub](https://t.me/LibreTube) +[Get it on F-Droid](https://f-droid.org/en/packages/com.github.libretube/) +[Get it on IzzyOnDroid](https://apt.izzysoft.de/fdroid/index/apk/com.github.libretube)
+[Get it on GitHub](https://github.com/libre-tube/LibreTube/releases/latest) +[Get it on GitHub](https://t.me/LibreTube)
+## 📔 About + +YouTube has an extremely invasive privacy policy which relies on using user data in unethical ways. They store a lot of your personal data - ranging from ideas, music taste, content, political opinions, and much more than you think. + +The project is aimed to improve the users privacy by being independent from Google and bypassing their data collection. Therefore the app is using the [Piped API](https://github.com/TeamPiped/Piped), which uses proxies to circumvent Googles data collection and includes some other additional features. + +> **⚠️ WARNING: This is a beta version, therefore you may encounter bugs. If you do, open an issue via our GitHub repository.** + ## 📱 Screenshots
@@ -47,14 +55,12 @@ ## 😇 Contributing -Whether you have ideas, translations, design changes, code cleaning, or real heavy code changes, help is always welcome.The more is done the better it gets! +Whether you have ideas, translations, design changes, code cleaning, or real heavy code changes, help is always welcome. The more is done, the better it gets! If creating a pull request, please make sure to format your code (preferred ktlint) before. If opening an issue without following the issue template, we will ignore the issue and force close it. -> **⚠️ WARNING: This is a beta version, therefore you may encounter bugs. If you do, open an issue via our github repository.** - ### 📝 Translation @@ -73,3 +79,6 @@ If opening an issue without following the issue template, we will ignore the iss GitLab

NotABug

+
+ ↥ Back to top +
diff --git a/app/build.gradle b/app/build.gradle index d0ed08e1b..f3948200e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,6 +1,9 @@ +import java.time.Instant + plugins { id 'com.android.application' id 'kotlin-android' + id 'kotlin-android-extensions' } android { @@ -10,8 +13,8 @@ android { applicationId 'com.github.libretube' minSdk 21 targetSdk 31 - versionCode 13 - versionName '0.3.3' + versionCode 16 + versionName '0.4.2' multiDexEnabled true testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' resValue "string", "app_name", "LibreTube" @@ -21,6 +24,16 @@ android { viewBinding true } + applicationVariants.all { variant -> + // use the date as version for debug builds + if (variant.name == 'debug') { + variant.outputs.each { output -> + output.versionCodeOverride = getUnixTime() + output.versionNameOverride = getUnixTime() + } + } + } + buildTypes { release { minifyEnabled true @@ -68,6 +81,7 @@ dependencies { implementation libs.androidx.navigation.fragment implementation libs.androidx.navigation.ui implementation libs.androidx.preference + implementation libs.androidx.work.runtime androidTestImplementation libs.androidx.test.junit androidTestImplementation libs.androidx.test.espressoCore @@ -79,15 +93,21 @@ dependencies { implementation(libs.exoplayer.extension.cronet) { exclude group: 'com.google.android.gms' } implementation libs.exoplayer.extension.mediasession - implementation libs.square.picasso implementation libs.square.retrofit implementation libs.square.retrofit.converterJackson // Do not update jackson annotations! It does not supports < API 26. implementation libs.jacksonAnnotations - implementation libs.mobileffmpeg - coreLibraryDesugaring libs.desugaring implementation libs.cronet.embedded - implementation libs.gson + implementation libs.cronet.okhttp + implementation libs.coil + + implementation libs.lifecycle.viewmodel + implementation libs.lifecycle.runtime + implementation libs.lifecycle.livedata } + +static def getUnixTime() { + return Instant.now().getEpochSecond() +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 6cd107af9..b7d85991e 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -14,7 +14,7 @@ # Uncomment this to preserve the line number information for # debugging stack traces. -#-keepattributes SourceFile,LineNumberTable +-keepattributes SourceFile,LineNumberTable # If you keep the line number information, uncomment this to # hide the original source file name. @@ -22,3 +22,9 @@ #uncomment for debug #-keepnames class ** -keep class com.github.libretube.obj.** { *; } + +# prevents android from removing it +-keep class com.github.libretube.update.** { *; } + +# prevents obfuscation in debug logs +-dontobfuscate \ No newline at end of file diff --git a/app/release/output-metadata.json b/app/release/output-metadata.json index 9e7eaaa0c..2dee107a4 100644 --- a/app/release/output-metadata.json +++ b/app/release/output-metadata.json @@ -16,10 +16,23 @@ } ], "attributes": [], - "versionCode": 7, - "versionName": "0.2.5", + "versionCode": 16, + "versionName": "0.4.2", "outputFile": "app-x86_64-release.apk" }, + { + "type": "ONE_OF_MANY", + "filters": [ + { + "filterType": "ABI", + "value": "arm64-v8a" + } + ], + "attributes": [], + "versionCode": 16, + "versionName": "0.4.2", + "outputFile": "app-arm64-v8a-release.apk" + }, { "type": "ONE_OF_MANY", "filters": [ @@ -29,8 +42,8 @@ } ], "attributes": [], - "versionCode": 7, - "versionName": "0.2.5", + "versionCode": 16, + "versionName": "0.4.2", "outputFile": "app-x86-release.apk" }, { @@ -42,22 +55,9 @@ } ], "attributes": [], - "versionCode": 7, - "versionName": "0.2.5", + "versionCode": 16, + "versionName": "0.4.2", "outputFile": "app-armeabi-v7a-release.apk" - }, - { - "type": "ONE_OF_MANY", - "filters": [ - { - "filterType": "ABI", - "value": "arm64-v8a" - } - ], - "attributes": [], - "versionCode": 7, - "versionName": "0.2.5", - "outputFile": "app-arm64-v8a-release.apk" } ], "elementType": "File" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8ed717cba..21478eef8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ @@ -7,7 +8,9 @@ - + - + android:theme="@style/Theme.Purple.Pure" + tools:targetApi="n"> + + + + + + + + android:exported="false" /> + + + + \ No newline at end of file diff --git a/app/src/main/java/com/github/libretube/Constants.kt b/app/src/main/java/com/github/libretube/Constants.kt index 7ef5409e0..7c5b6c075 100644 --- a/app/src/main/java/com/github/libretube/Constants.kt +++ b/app/src/main/java/com/github/libretube/Constants.kt @@ -12,6 +12,7 @@ const val WEBSITE_URL = "https://libre-tube.github.io/" const val DONATE_URL = "https://github.com/libre-tube/LibreTube#donate" const val GITHUB_URL = "https://github.com/libre-tube/LibreTube" const val PIPED_GITHUB_URL = "https://github.com/TeamPiped/Piped" +const val WEBLATE_URL = "https://hosted.weblate.org/projects/libretube/libretube/" /** * Social media links for the community fragment @@ -32,3 +33,19 @@ const val YOUTUBE_FRONTEND_URL = "https://www.youtube.com" * Retrofit Instance */ const val PIPED_API_URL = "https://pipedapi.kavin.rocks/" + +/** + * Notification IDs + */ +const val PLAYER_NOTIFICATION_ID = 1 +const val PUSH_NOTIFICATION_ID = 2 +const val DOWNLOAD_PENDING_NOTIFICATION_ID = 3 +const val DOWNLOAD_FAILURE_NOTIFICATION_ID = 4 +const val DOWNLOAD_SUCCESS_NOTIFICATION_ID = 5 + +/** + * Notification Channel IDs + */ +const val DOWNLOAD_CHANNEL_ID = "download_service" +const val BACKGROUND_CHANNEL_ID = "background_mode" +const val PUSH_CHANNEL_ID = "notification_worker" diff --git a/app/src/main/java/com/github/libretube/Globals.kt b/app/src/main/java/com/github/libretube/Globals.kt index 7d4ff4ddd..5db9eabea 100644 --- a/app/src/main/java/com/github/libretube/Globals.kt +++ b/app/src/main/java/com/github/libretube/Globals.kt @@ -1,7 +1,22 @@ package com.github.libretube +/** + * Global variables can be stored here + */ object Globals { - var isFullScreen = false - var isMiniPlayerVisible = false - var isCurrentViewMainSettings = true + // for the player fragment + var IS_FULL_SCREEN = false + var MINI_PLAYER_VISIBLE = false + + // for the data saver mode + var DATA_SAVER_MODE_ENABLED = false + + // for downloads + var IS_DOWNLOAD_RUNNING = false + + // for playlists + var SELECTED_PLAYLIST_ID: String? = null + + // history of played videos in the current lifecycle + val playingQueue = mutableListOf() } diff --git a/app/src/main/java/com/github/libretube/MyApp.kt b/app/src/main/java/com/github/libretube/MyApp.kt index 2995e496a..11cfacfd7 100644 --- a/app/src/main/java/com/github/libretube/MyApp.kt +++ b/app/src/main/java/com/github/libretube/MyApp.kt @@ -4,12 +4,69 @@ import android.app.Application import android.app.NotificationChannel import android.app.NotificationManager import android.os.Build +import android.os.StrictMode +import android.os.StrictMode.VmPolicy +import androidx.work.ExistingPeriodicWorkPolicy +import com.github.libretube.preferences.PreferenceHelper +import com.github.libretube.preferences.PreferenceKeys +import com.github.libretube.util.ExceptionHandler +import com.github.libretube.util.NotificationHelper +import com.github.libretube.util.RetrofitInstance class MyApp : Application() { override fun onCreate() { super.onCreate() + /** + * initialize the needed [NotificationChannel]s for DownloadService and BackgroundMode + */ initializeNotificationChannels() + + /** + * set the applicationContext as context for the [PreferenceHelper] + */ + PreferenceHelper.setContext(applicationContext) + + /** + * bypassing fileUriExposedException, see https://stackoverflow.com/questions/38200282/android-os-fileuriexposedexception-file-storage-emulated-0-test-txt-exposed + */ + val builder = VmPolicy.Builder() + StrictMode.setVmPolicy(builder.build()) + + /** + * set the api and the auth api url + */ + setRetrofitApiUrls() + + /** + * initialize the notification listener in the background + */ + NotificationHelper.enqueueWork(this, ExistingPeriodicWorkPolicy.KEEP) + + /** + * Handler for uncaught exceptions + */ + val defaultExceptionHandler = Thread.getDefaultUncaughtExceptionHandler() + val exceptionHandler = ExceptionHandler(defaultExceptionHandler) + Thread.setDefaultUncaughtExceptionHandler(exceptionHandler) + } + + /** + * set the api urls needed for the [RetrofitInstance] + */ + private fun setRetrofitApiUrls() { + RetrofitInstance.url = + PreferenceHelper.getString(PreferenceKeys.FETCH_INSTANCE, PIPED_API_URL) + // set auth instance + RetrofitInstance.authUrl = + if (PreferenceHelper.getBoolean(PreferenceKeys.AUTH_INSTANCE_TOGGLE, false)) { + PreferenceHelper.getString( + PreferenceKeys.AUTH_INSTANCE, + PIPED_API_URL + ) + } else { + RetrofitInstance.url + } } /** @@ -17,17 +74,23 @@ class MyApp : Application() { */ private fun initializeNotificationChannels() { createNotificationChannel( - "download_service", + DOWNLOAD_CHANNEL_ID, "Download Service", - "DownloadService", + "Shows a notification when downloading media.", NotificationManager.IMPORTANCE_NONE ) createNotificationChannel( - "background_mode", + BACKGROUND_CHANNEL_ID, "Background Mode", "Shows a notification with buttons to control the audio player", NotificationManager.IMPORTANCE_LOW ) + createNotificationChannel( + PUSH_CHANNEL_ID, + "Notification Worker", + "Shows a notification when new streams are available.", + NotificationManager.IMPORTANCE_DEFAULT + ) } private fun createNotificationChannel( @@ -46,9 +109,4 @@ class MyApp : Application() { notificationManager.createNotificationChannel(channel) } } - - companion object { - @JvmField - var seekTo: Long? = 0 - } } diff --git a/app/src/main/java/com/github/libretube/preferences/AboutFragment.kt b/app/src/main/java/com/github/libretube/activities/AboutActivity.kt similarity index 60% rename from app/src/main/java/com/github/libretube/preferences/AboutFragment.kt rename to app/src/main/java/com/github/libretube/activities/AboutActivity.kt index 0605e6707..a6a23a862 100644 --- a/app/src/main/java/com/github/libretube/preferences/AboutFragment.kt +++ b/app/src/main/java/com/github/libretube/activities/AboutActivity.kt @@ -1,48 +1,35 @@ -package com.github.libretube.preferences +package com.github.libretube.activities import android.content.Intent import android.net.Uri import android.os.Build import android.os.Bundle import android.text.Html -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.Fragment import com.github.libretube.DONATE_URL import com.github.libretube.GITHUB_URL import com.github.libretube.PIPED_GITHUB_URL import com.github.libretube.R +import com.github.libretube.WEBLATE_URL import com.github.libretube.WEBSITE_URL -import com.github.libretube.activities.SettingsActivity -import com.github.libretube.databinding.FragmentAboutBinding -import com.github.libretube.util.ThemeHelper.getThemeColor +import com.github.libretube.databinding.ActivityAboutBinding +import com.github.libretube.extensions.BaseActivity import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar -class AboutFragment : Fragment() { - private lateinit var binding: FragmentAboutBinding +class AboutActivity : BaseActivity() { + private lateinit var binding: ActivityAboutBinding - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - binding = FragmentAboutBinding.inflate(layoutInflater) - return binding.root - } + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - val settingsActivity = activity as SettingsActivity - settingsActivity.changeTopBarText(getString(R.string.about)) + binding = ActivityAboutBinding.inflate(layoutInflater) + setContentView(binding.root) binding.website.setOnClickListener { openLinkFromHref(WEBSITE_URL) } binding.website.setOnLongClickListener { - val text = context?.getString(R.string.website_summary)!! + val text = getString(R.string.website_summary) showSnackBar(text) true } @@ -51,7 +38,16 @@ class AboutFragment : Fragment() { openLinkFromHref(PIPED_GITHUB_URL) } binding.piped.setOnLongClickListener { - val text = context?.getString(R.string.piped_summary)!! + val text = getString(R.string.piped_summary) + showSnackBar(text) + true + } + + binding.translate.setOnClickListener { + openLinkFromHref(WEBLATE_URL) + } + binding.translate.setOnLongClickListener { + val text = getString(R.string.translate_summary) showSnackBar(text) true } @@ -60,7 +56,7 @@ class AboutFragment : Fragment() { openLinkFromHref(DONATE_URL) } binding.donate.setOnLongClickListener { - val text = context?.getString(R.string.donate_summary)!! + val text = getString(R.string.donate_summary) showSnackBar(text) true } @@ -69,7 +65,7 @@ class AboutFragment : Fragment() { openLinkFromHref(GITHUB_URL) } binding.github.setOnLongClickListener { - val text = context?.getString(R.string.contributing_summary)!! + val text = getString(R.string.contributing_summary) showSnackBar(text) true } @@ -78,7 +74,7 @@ class AboutFragment : Fragment() { showLicense() } binding.license.setOnLongClickListener { - val text = context?.getString(R.string.license_summary)!! + val text = getString(R.string.license_summary) showSnackBar(text) true } @@ -94,10 +90,6 @@ class AboutFragment : Fragment() { val snackBar = Snackbar .make(binding.root, text, Snackbar.LENGTH_LONG) - // set snackBar color - snackBar.setBackgroundTint(getThemeColor(requireContext(), R.attr.colorSurface)) - snackBar.setTextColor(getThemeColor(requireContext(), R.attr.colorPrimary)) - // prevent the text from being partially hidden snackBar.setTextMaxLines(3) @@ -105,7 +97,6 @@ class AboutFragment : Fragment() { } private fun showLicense() { - val assets = view?.context?.assets val licenseString = assets ?.open("gpl3.html") ?.bufferedReader() @@ -119,7 +110,7 @@ class AboutFragment : Fragment() { Html.fromHtml(licenseString.toString()) } - MaterialAlertDialogBuilder(requireContext()) + MaterialAlertDialogBuilder(this) .setPositiveButton(getString(R.string.okay)) { _, _ -> } .setMessage(licenseHtml) .create() diff --git a/app/src/main/java/com/github/libretube/preferences/CommunityFragment.kt b/app/src/main/java/com/github/libretube/activities/CommunityActivity.kt similarity index 51% rename from app/src/main/java/com/github/libretube/preferences/CommunityFragment.kt rename to app/src/main/java/com/github/libretube/activities/CommunityActivity.kt index 90b2d9a5b..7fe9f2211 100644 --- a/app/src/main/java/com/github/libretube/preferences/CommunityFragment.kt +++ b/app/src/main/java/com/github/libretube/activities/CommunityActivity.kt @@ -1,38 +1,24 @@ -package com.github.libretube.preferences +package com.github.libretube.activities import android.content.Intent import android.net.Uri import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.Fragment import com.github.libretube.DISCORD_URL import com.github.libretube.MATRIX_URL -import com.github.libretube.R import com.github.libretube.REDDIT_URL import com.github.libretube.TELEGRAM_URL import com.github.libretube.TWITTER_URL -import com.github.libretube.activities.SettingsActivity -import com.github.libretube.databinding.FragmentCommunityBinding +import com.github.libretube.databinding.ActivityCommunityBinding +import com.github.libretube.extensions.BaseActivity -class CommunityFragment : Fragment() { - private lateinit var binding: FragmentCommunityBinding +class CommunityActivity : BaseActivity() { + private lateinit var binding: ActivityCommunityBinding - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - binding = FragmentCommunityBinding.inflate(layoutInflater) - return binding.root - } + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - val settingsActivity = activity as SettingsActivity - settingsActivity.changeTopBarText(getString(R.string.community)) + binding = ActivityCommunityBinding.inflate(layoutInflater) + setContentView(binding.root) binding.telegram.setOnClickListener { openLinkFromHref(TELEGRAM_URL) diff --git a/app/src/main/java/com/github/libretube/activities/MainActivity.kt b/app/src/main/java/com/github/libretube/activities/MainActivity.kt index dc79c69b5..901945509 100644 --- a/app/src/main/java/com/github/libretube/activities/MainActivity.kt +++ b/app/src/main/java/com/github/libretube/activities/MainActivity.kt @@ -1,8 +1,7 @@ package com.github.libretube.activities -import android.app.Activity -import android.content.Context import android.content.Intent +import android.content.pm.ActivityInfo import android.content.res.Configuration import android.net.Uri import android.os.Build @@ -10,70 +9,81 @@ import android.os.Bundle import android.os.Handler import android.os.Looper import android.util.Log +import android.view.Menu +import android.view.MenuItem import android.view.View import android.view.WindowInsets import android.view.WindowInsetsController import android.view.WindowManager -import android.view.inputmethod.InputMethodManager import android.widget.LinearLayout -import androidx.appcompat.app.AppCompatActivity +import android.widget.Toast +import androidx.appcompat.widget.SearchView import androidx.constraintlayout.motion.widget.MotionLayout import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.os.bundleOf -import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModelProvider import androidx.navigation.NavController import androidx.navigation.findNavController import androidx.navigation.ui.setupWithNavController +import coil.ImageLoader import com.github.libretube.Globals -import com.github.libretube.PIPED_API_URL import com.github.libretube.R import com.github.libretube.databinding.ActivityMainBinding +import com.github.libretube.dialogs.ErrorDialog +import com.github.libretube.extensions.BaseActivity import com.github.libretube.fragments.PlayerFragment +import com.github.libretube.models.SearchViewModel import com.github.libretube.preferences.PreferenceHelper +import com.github.libretube.preferences.PreferenceKeys import com.github.libretube.services.ClosingService import com.github.libretube.util.ConnectionHelper import com.github.libretube.util.CronetHelper import com.github.libretube.util.LocaleHelper -import com.github.libretube.util.RetrofitInstance import com.github.libretube.util.ThemeHelper +import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.elevation.SurfaceColors import com.google.android.material.navigation.NavigationBarView -class MainActivity : AppCompatActivity() { +class MainActivity : BaseActivity() { val TAG = "MainActivity" lateinit var binding: ActivityMainBinding lateinit var navController: NavController private var startFragmentId = R.id.homeFragment + var autoRotationEnabled = false + + lateinit var searchView: SearchView override fun onCreate(savedInstanceState: Bundle?) { - // set the app theme (e.g. Material You) - ThemeHelper.updateTheme(this) - // set the language LocaleHelper.updateLanguage(this) super.onCreate(savedInstanceState) + autoRotationEnabled = PreferenceHelper.getBoolean(PreferenceKeys.AUTO_ROTATION, false) + + // enable auto rotation if turned on + requestedOrientation = if (autoRotationEnabled) ActivityInfo.SCREEN_ORIENTATION_USER + else ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT + // start service that gets called on closure - startService(Intent(this, ClosingService::class.java)) + try { + startService(Intent(this, ClosingService::class.java)) + } catch (e: Exception) { + e.printStackTrace() + } CronetHelper.initCronet(this.applicationContext) + ConnectionHelper.imageLoader = ImageLoader.Builder(this.applicationContext) + .callFactory(CronetHelper.callFactory) + .build() - RetrofitInstance.url = - PreferenceHelper.getString(this, "selectInstance", PIPED_API_URL)!! - // set auth instance - RetrofitInstance.authUrl = - if (PreferenceHelper.getBoolean(this, "auth_instance_toggle", false)) { - PreferenceHelper.getString( - this, - "selectAuthInstance", - PIPED_API_URL - )!! - } else { - RetrofitInstance.url - } + // save whether the data saver mode is enabled + Globals.DATA_SAVER_MODE_ENABLED = PreferenceHelper.getBoolean( + PreferenceKeys.DATA_SAVER_MODE, + false + ) // show noInternet Activity if no internet available on app startup if (!ConnectionHelper.isNetworkAvailable(this)) { @@ -83,6 +93,9 @@ class MainActivity : AppCompatActivity() { binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) + // set the action bar for the activity + setSupportActionBar(binding.toolbar) + navController = findNavController(R.id.fragment) binding.bottomNav.setupWithNavController(navController) @@ -93,17 +106,19 @@ class MainActivity : AppCompatActivity() { window.navigationBarColor = color // hide the trending page if enabled - val hideTrendingPage = PreferenceHelper.getBoolean(this, "hide_trending_page", false) + val hideTrendingPage = + PreferenceHelper.getBoolean(PreferenceKeys.HIDE_TRENDING_PAGE, false) if (hideTrendingPage) binding.bottomNav.menu.findItem(R.id.homeFragment).isVisible = false // save start tab fragment id - startFragmentId = when (PreferenceHelper.getString(this, "default_tab", "home")) { - "home" -> R.id.homeFragment - "subscriptions" -> R.id.subscriptionsFragment - "library" -> R.id.libraryFragment - else -> R.id.homeFragment - } + startFragmentId = + when (PreferenceHelper.getString(PreferenceKeys.DEFAULT_TAB, "home")) { + "home" -> R.id.homeFragment + "subscriptions" -> R.id.subscriptionsFragment + "library" -> R.id.libraryFragment + else -> R.id.homeFragment + } // set default tab as start fragment navController.graph.setStartDestination(startFragmentId) @@ -112,7 +127,7 @@ class MainActivity : AppCompatActivity() { navController.navigate(startFragmentId) val labelVisibilityMode = when ( - PreferenceHelper.getString(this, "label_visibility", "always") + PreferenceHelper.getString(PreferenceKeys.LABEL_VISIBILITY, "always") ) { "always" -> NavigationBarView.LABEL_VISIBILITY_LABELED "selected" -> NavigationBarView.LABEL_VISIBILITY_SELECTED @@ -121,10 +136,13 @@ class MainActivity : AppCompatActivity() { } binding.bottomNav.labelVisibilityMode = labelVisibilityMode + binding.bottomNav.setOnApplyWindowInsetsListener(null) + binding.bottomNav.setOnItemSelectedListener { // clear backstack if it's the start fragment if (startFragmentId == it.itemId) navController.backQueue.clear() // set menu item on click listeners + removeSearchFocus() when (it.itemId) { R.id.homeFragment -> { navController.navigate(R.id.homeFragment) @@ -140,22 +158,140 @@ class MainActivity : AppCompatActivity() { } binding.toolbar.title = ThemeHelper.getStyledAppName(this) + } - binding.toolbar.setNavigationOnClickListener { - // settings activity stuff - val intent = Intent(this, SettingsActivity::class.java) - startActivity(intent) - } + /** + * handle error logs + */ + val log = PreferenceHelper.getErrorLog() + if (log != "") ErrorDialog().show(supportFragmentManager, null) - binding.toolbar.setOnMenuItemClickListener { - when (it.itemId) { - R.id.action_search -> { - navController.navigate(R.id.searchFragment) + setupBreakReminder() + } + + /** + * Show a break reminder when watched too long + */ + private fun setupBreakReminder() { + val breakReminderPref = PreferenceHelper.getString( + PreferenceKeys.BREAK_REMINDER, + "disabled" + ) + if (breakReminderPref == "disabled") return + Handler(Looper.getMainLooper()).postDelayed( + { + try { + MaterialAlertDialogBuilder(this) + .setTitle(getString(R.string.share_with_time)) + .setMessage( + getString( + R.string.already_spent_time, + breakReminderPref + ) + ) + .setPositiveButton(R.string.okay, null) + .show() + } catch (e: Exception) { + kotlin.runCatching { + Toast.makeText(this, R.string.take_a_break, Toast.LENGTH_LONG).show() } } - false + }, + breakReminderPref.toLong() * 60 * 1000 + ) + } + + private fun removeSearchFocus() { + searchView.setQuery("", false) + searchView.clearFocus() + searchView.onActionViewCollapsed() + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + // Inflate the menu; this adds items to the action bar if it is present. + menuInflater.inflate(R.menu.action_bar, menu) + + // stuff for the search in the topBar + val searchItem = menu.findItem(R.id.action_search) + searchView = searchItem.actionView as SearchView + + val searchViewModel = ViewModelProvider(this)[SearchViewModel::class.java] + + searchView.setOnSearchClickListener { + if (navController.currentDestination?.id != R.id.searchResultFragment) { + searchViewModel.setQuery(null) + navController.navigate(R.id.searchFragment) } } + + searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String?): Boolean { + val bundle = Bundle() + bundle.putString("query", query) + navController.navigate(R.id.searchResultFragment, bundle) + return true + } + + override fun onQueryTextChange(newText: String?): Boolean { + if (navController.currentDestination?.id != R.id.searchFragment) { + val bundle = Bundle() + bundle.putString("query", newText) + navController.navigate(R.id.searchFragment, bundle) + } else { + searchViewModel.setQuery(newText) + } + return true + } + }) + + searchItem.setOnActionExpandListener( + object : MenuItem.OnActionExpandListener { + override fun onMenuItemActionExpand(p0: MenuItem?): Boolean { + return true + } + + override fun onMenuItemActionCollapse(p0: MenuItem?): Boolean { + val currentFragmentId = navController.currentDestination?.id + if (currentFragmentId == R.id.searchFragment || currentFragmentId == R.id.searchResultFragment) { + onBackPressed() + } + return true + } + } + ) + + searchView.setOnCloseListener { + if (navController.currentDestination?.id == R.id.searchFragment) { + searchViewModel.setQuery(null) + onBackPressed() + } + false + } + return super.onCreateOptionsMenu(menu) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + // Handle action bar item clicks here. The action bar will + // automatically handle clicks on the Home/Up button, so long + // as you specify a parent activity in AndroidManifest.xml. + return when (item.itemId) { + R.id.action_settings -> { + val settingsIntent = Intent(this, SettingsActivity::class.java) + startActivity(settingsIntent) + true + } + R.id.action_about -> { + val aboutIntent = Intent(this, AboutActivity::class.java) + startActivity(aboutIntent) + true + } + R.id.action_community -> { + val communityIntent = Intent(this, CommunityActivity::class.java) + startActivity(communityIntent) + true + } + else -> super.onOptionsItemSelected(item) + } } override fun onStart() { @@ -163,92 +299,95 @@ class MainActivity : AppCompatActivity() { val intentData: Uri? = intent?.data // check whether an URI got submitted over the intent data if (intentData != null && intentData.host != null && intentData.path != null) { - Log.d("intentData", "${intentData.host} ${intentData.path} ") + Log.d(TAG, "intentData: ${intentData.host} ${intentData.path} ") // load the URI of the submitted link (e.g. video) loadIntentData(intentData) } } private fun loadIntentData(data: Uri) { - // channel - if (data.path!!.contains("/channel/") || + if (data.path!!.contains("/channel/") + ) { + val channelId = data.path!! + .replace("/channel/", "") + + loadChannel(channelId = channelId) + } else if ( data.path!!.contains("/c/") || data.path!!.contains("/user/") ) { - Log.i(TAG, "URI Type: Channel") - var channel = data.path - channel = channel!!.replace("/c/", "") - channel = channel.replace("/user/", "") - val bundle = bundleOf("channel_id" to channel) - navController.navigate(R.id.channelFragment, bundle) - } else if (data.path!!.contains("/playlist")) { - Log.i(TAG, "URI Type: Playlist") - var playlist = data.query!! - if (playlist.contains("&")) { - val playlists = playlist.split("&") - for (v in playlists) { + val channelName = data.path!! + .replace("/c/", "") + .replace("/user/", "") + + loadChannel(channelName = channelName) + } else if ( + data.path!!.contains("/playlist") + ) { + var playlistId = data.query!! + if (playlistId.contains("&")) { + for (v in playlistId.split("&")) { if (v.contains("list=")) { - playlist = v + playlistId = v.replace("list=", "") break } } + } else { + playlistId = playlistId.replace("list=", "") } - playlist = playlist.replace("list=", "") - val bundle = bundleOf("playlist_id" to playlist) - navController.navigate(R.id.playlistFragment, bundle) - } else if (data.path!!.contains("/shorts/") || + + loadPlaylist(playlistId) + } else if ( + data.path!!.contains("/shorts/") || data.path!!.contains("/embed/") || data.path!!.contains("/v/") ) { - Log.i(TAG, "URI Type: Video") - val watch = data.path!! + val videoId = data.path!! .replace("/shorts/", "") .replace("/v/", "") .replace("/embed/", "") - val bundle = Bundle() - bundle.putString("videoId", watch) - // for time stamped links - if (data.query != null && data.query?.contains("t=")!!) { - val timeStamp = data.query.toString().split("t=")[1] - bundle.putLong("timeStamp", timeStamp.toLong()) - } - loadWatch(bundle) + + loadVideo(videoId, data.query) } else if (data.path!!.contains("/watch") && data.query != null) { - Log.d("dafaq", data.query!!) - var watch = data.query!! - if (watch.contains("&")) { - val watches = watch.split("&") + var videoId = data.query!! + + if (videoId.contains("&")) { + val watches = videoId.split("&") for (v in watches) { if (v.contains("v=")) { - watch = v + videoId = v.replace("v=", "") break } } + } else { + videoId = videoId + .replace("v=", "") } - val bundle = Bundle() - bundle.putString("videoId", watch.replace("v=", "")) - // for time stamped links - if (data.query != null && data.query?.contains("t=")!!) { - val timeStamp = data.query.toString().split("t=")[1] - bundle.putLong("timeStamp", timeStamp.toLong()) - } - loadWatch(bundle) + + loadVideo(videoId, data.query) } else { - val watch = data.path!!.replace("/", "") - val bundle = Bundle() - bundle.putString("videoId", watch) - // for time stamped links - if (data.query != null && data.query?.contains("t=")!!) { - val timeStamp = data.query.toString().split("t=")[1] - bundle.putLong("timeStamp", timeStamp.toLong()) - } - loadWatch(bundle) + val videoId = data.path!!.replace("/", "") + + loadVideo(videoId, data.query) } } - private fun loadWatch(bundle: Bundle) { + private fun loadVideo(videoId: String, query: String?) { + Log.i(TAG, "URI type: Video") + + val bundle = Bundle() + Log.e(TAG, videoId) + + // for time stamped links + if (query != null && query.contains("t=")) { + val timeStamp = query.toString().split("t=")[1] + bundle.putLong("timeStamp", timeStamp.toLong()) + } + + bundle.putString("videoId", videoId) val frag = PlayerFragment() frag.arguments = bundle + supportFragmentManager.beginTransaction() .remove(PlayerFragment()) .commit() @@ -262,13 +401,36 @@ class MainActivity : AppCompatActivity() { }, 100) } + private fun loadChannel( + channelId: String? = null, + channelName: String? = null + ) { + Log.i(TAG, "Uri Type: Channel") + + val bundle = if (channelId != null) bundleOf("channel_id" to channelId) + else bundleOf("channel_name" to channelName) + navController.navigate(R.id.channelFragment, bundle) + } + + private fun loadPlaylist(playlistId: String) { + Log.i(TAG, "Uri Type: Playlist") + + val bundle = bundleOf("playlist_id" to playlistId) + navController.navigate(R.id.playlistFragment, bundle) + } + override fun onBackPressed() { + // remove focus from search + removeSearchFocus() + navController.popBackStack(R.id.searchFragment, false) + if (binding.mainMotionLayout.progress == 0F) { try { minimizePlayer() } catch (e: Exception) { if (navController.currentDestination?.id == startFragmentId) { - super.onBackPressed() + // close app + moveTaskToBack(true) } else { navController.popBackStack() } @@ -292,7 +454,9 @@ class MainActivity : AppCompatActivity() { enableTransition(R.id.yt_transition, true) } findViewById(R.id.linLayout).visibility = View.VISIBLE - Globals.isFullScreen = false + Globals.IS_FULL_SCREEN = false + requestedOrientation = if (autoRotationEnabled) ActivityInfo.SCREEN_ORIENTATION_USER + else ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT } override fun onConfigurationChanged(newConfig: Configuration) { @@ -330,9 +494,13 @@ class MainActivity : AppCompatActivity() { or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION ) } + + window.setFlags(WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS, WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS) } private fun unsetFullscreen() { + window.clearFlags(WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { window.attributes.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT @@ -358,12 +526,3 @@ class MainActivity : AppCompatActivity() { } } } - -fun Fragment.hideKeyboard() { - view?.let { activity?.hideKeyboard(it) } -} - -fun Context.hideKeyboard(view: View) { - val inputMethodManager = getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager - inputMethodManager.hideSoftInputFromWindow(view.windowToken, 0) -} diff --git a/app/src/main/java/com/github/libretube/activities/NoInternetActivity.kt b/app/src/main/java/com/github/libretube/activities/NoInternetActivity.kt index 53f08a650..e60ae1ee4 100644 --- a/app/src/main/java/com/github/libretube/activities/NoInternetActivity.kt +++ b/app/src/main/java/com/github/libretube/activities/NoInternetActivity.kt @@ -2,18 +2,17 @@ package com.github.libretube.activities import android.content.Intent import android.os.Bundle -import androidx.appcompat.app.AppCompatActivity import com.github.libretube.R import com.github.libretube.databinding.ActivityNointernetBinding +import com.github.libretube.extensions.BaseActivity import com.github.libretube.util.ConnectionHelper import com.github.libretube.util.ThemeHelper import com.google.android.material.snackbar.Snackbar -class NoInternetActivity : AppCompatActivity() { +class NoInternetActivity : BaseActivity() { private lateinit var binding: ActivityNointernetBinding override fun onCreate(savedInstanceState: Bundle?) { - ThemeHelper.updateTheme(this) super.onCreate(savedInstanceState) binding = ActivityNointernetBinding.inflate(layoutInflater) diff --git a/app/src/main/java/com/github/libretube/activities/RouterActivity.kt b/app/src/main/java/com/github/libretube/activities/RouterActivity.kt index a90e218da..b9e981b50 100644 --- a/app/src/main/java/com/github/libretube/activities/RouterActivity.kt +++ b/app/src/main/java/com/github/libretube/activities/RouterActivity.kt @@ -5,11 +5,11 @@ import android.content.pm.PackageManager import android.net.Uri import android.os.Bundle import android.util.Log -import androidx.appcompat.app.AppCompatActivity import com.github.libretube.R +import com.github.libretube.extensions.BaseActivity import com.github.libretube.util.ThemeHelper -class RouterActivity : AppCompatActivity() { +class RouterActivity : BaseActivity() { val TAG = "RouterActivity" override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) diff --git a/app/src/main/java/com/github/libretube/activities/SettingsActivity.kt b/app/src/main/java/com/github/libretube/activities/SettingsActivity.kt index 5adea738a..e33b0019f 100644 --- a/app/src/main/java/com/github/libretube/activities/SettingsActivity.kt +++ b/app/src/main/java/com/github/libretube/activities/SettingsActivity.kt @@ -1,32 +1,19 @@ package com.github.libretube.activities -import android.os.Build import android.os.Bundle -import androidx.appcompat.app.AppCompatActivity -import com.github.libretube.Globals import com.github.libretube.R import com.github.libretube.databinding.ActivitySettingsBinding +import com.github.libretube.extensions.BaseActivity import com.github.libretube.preferences.MainSettings -import com.github.libretube.util.ThemeHelper -class SettingsActivity : AppCompatActivity() { +class SettingsActivity : BaseActivity() { val TAG = "SettingsActivity" lateinit var binding: ActivitySettingsBinding override fun onCreate(savedInstanceState: Bundle?) { - ThemeHelper.updateTheme(this) - - // apply the theme for the preference dialogs - setTheme(R.style.MaterialAlertDialog) - super.onCreate(savedInstanceState) binding = ActivitySettingsBinding.inflate(layoutInflater) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - overridePendingTransition(50, 50) - } - binding.root.alpha = 0F - binding.root.animate().alpha(1F).duration = 300 setContentView(binding.root) @@ -43,16 +30,18 @@ class SettingsActivity : AppCompatActivity() { } override fun onBackPressed() { - if (Globals.isCurrentViewMainSettings) { - super.onBackPressed() - finishAndRemoveTask() - } else { - Globals.isCurrentViewMainSettings = true - supportFragmentManager - .beginTransaction() - .replace(R.id.settings, MainSettings()) - .commit() - changeTopBarText(getString(R.string.settings)) + when (supportFragmentManager.findFragmentById(R.id.settings)) { + is MainSettings -> { + super.onBackPressed() + finishAndRemoveTask() + } + else -> { + supportFragmentManager + .beginTransaction() + .replace(R.id.settings, MainSettings()) + .commit() + changeTopBarText(getString(R.string.settings)) + } } } diff --git a/app/src/main/java/com/github/libretube/adapters/ChannelAdapter.kt b/app/src/main/java/com/github/libretube/adapters/ChannelAdapter.kt index ed5b83e58..3574d9117 100644 --- a/app/src/main/java/com/github/libretube/adapters/ChannelAdapter.kt +++ b/app/src/main/java/com/github/libretube/adapters/ChannelAdapter.kt @@ -1,19 +1,18 @@ package com.github.libretube.adapters -import android.os.Bundle import android.text.format.DateUtils import android.view.LayoutInflater import android.view.ViewGroup -import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.FragmentManager import androidx.recyclerview.widget.RecyclerView -import com.github.libretube.R -import com.github.libretube.databinding.VideoChannelRowBinding +import com.github.libretube.databinding.VideoRowBinding import com.github.libretube.dialogs.VideoOptionsDialog -import com.github.libretube.fragments.PlayerFragment import com.github.libretube.obj.StreamItem +import com.github.libretube.util.ConnectionHelper +import com.github.libretube.util.NavigationHelper import com.github.libretube.util.formatShort -import com.squareup.picasso.Picasso +import com.github.libretube.util.setWatchProgressLength +import com.github.libretube.util.toID class ChannelAdapter( private val videoFeed: MutableList, @@ -26,47 +25,39 @@ class ChannelAdapter( } fun updateItems(newItems: List) { + val feedSize = videoFeed.size videoFeed.addAll(newItems) - notifyDataSetChanged() + notifyItemRangeInserted(feedSize, newItems.size) } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ChannelViewHolder { val layoutInflater = LayoutInflater.from(parent.context) - val binding = VideoChannelRowBinding.inflate(layoutInflater, parent, false) + val binding = VideoRowBinding.inflate(layoutInflater, parent, false) return ChannelViewHolder(binding) } override fun onBindViewHolder(holder: ChannelViewHolder, position: Int) { val trending = videoFeed[position] holder.binding.apply { - channelDescription.text = trending.title - channelViews.text = + videoTitle.text = trending.title + videoInfo.text = trending.views.formatShort() + " • " + DateUtils.getRelativeTimeSpanString(trending.uploaded!!) - channelDuration.text = + thumbnailDuration.text = DateUtils.formatElapsedTime(trending.duration!!) - Picasso.get().load(trending.thumbnail).into(channelThumbnail) + ConnectionHelper.loadImage(trending.thumbnail, thumbnail) root.setOnClickListener { - var bundle = Bundle() - bundle.putString("videoId", trending.url!!.replace("/watch?v=", "")) - var frag = PlayerFragment() - frag.arguments = bundle - val activity = root.context as AppCompatActivity - activity.supportFragmentManager.beginTransaction() - .remove(PlayerFragment()) - .commit() - activity.supportFragmentManager.beginTransaction() - .replace(R.id.container, frag) - .commitNow() + NavigationHelper.navigateVideo(root.context, trending.url) } + val videoId = trending.url.toID() root.setOnLongClickListener { - val videoId = trending.url!!.replace("/watch?v=", "") - VideoOptionsDialog(videoId, root.context) - .show(childFragmentManager, VideoOptionsDialog.TAG) + VideoOptionsDialog(videoId) + .show(childFragmentManager, VideoOptionsDialog::class.java.name) true } + watchProgress.setWatchProgressLength(videoId, trending.duration!!) } } } -class ChannelViewHolder(val binding: VideoChannelRowBinding) : RecyclerView.ViewHolder(binding.root) +class ChannelViewHolder(val binding: VideoRowBinding) : RecyclerView.ViewHolder(binding.root) diff --git a/app/src/main/java/com/github/libretube/adapters/ChaptersAdapter.kt b/app/src/main/java/com/github/libretube/adapters/ChaptersAdapter.kt index 6a28be080..dba75dfc4 100644 --- a/app/src/main/java/com/github/libretube/adapters/ChaptersAdapter.kt +++ b/app/src/main/java/com/github/libretube/adapters/ChaptersAdapter.kt @@ -1,18 +1,21 @@ package com.github.libretube.adapters +import android.graphics.Color import android.view.LayoutInflater import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView import com.github.libretube.databinding.ChapterColumnBinding import com.github.libretube.obj.ChapterSegment +import com.github.libretube.util.ConnectionHelper +import com.github.libretube.util.ThemeHelper import com.google.android.exoplayer2.ExoPlayer -import com.squareup.picasso.Picasso class ChaptersAdapter( private val chapters: List, private val exoPlayer: ExoPlayer ) : RecyclerView.Adapter() { val TAG = "ChaptersAdapter" + private var selectedPosition = 0 override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ChaptersViewHolder { val layoutInflater = LayoutInflater.from(parent.context) @@ -23,16 +26,30 @@ class ChaptersAdapter( override fun onBindViewHolder(holder: ChaptersViewHolder, position: Int) { val chapter = chapters[position] holder.binding.apply { - Picasso.get().load(chapter.image).fit().centerCrop().into(chapterImage) + ConnectionHelper.loadImage(chapter.image, chapterImage) chapterTitle.text = chapter.title + if (selectedPosition == position) { + // get the color for highlighted controls + val color = + ThemeHelper.getThemeColor(root.context, android.R.attr.colorControlHighlight) + chapterLL.setBackgroundColor(color) + } else chapterLL.setBackgroundColor(Color.TRANSPARENT) root.setOnClickListener { + updateSelectedPosition(position) val chapterStart = chapter.start!! * 1000 // s -> ms exoPlayer.seekTo(chapterStart) } } } + fun updateSelectedPosition(newPosition: Int) { + val oldPosition = selectedPosition + selectedPosition = newPosition + notifyItemChanged(oldPosition) + notifyItemChanged(newPosition) + } + override fun getItemCount(): Int { return chapters.size } diff --git a/app/src/main/java/com/github/libretube/adapters/CommentsAdapter.kt b/app/src/main/java/com/github/libretube/adapters/CommentsAdapter.kt index cbac097ad..688f33514 100644 --- a/app/src/main/java/com/github/libretube/adapters/CommentsAdapter.kt +++ b/app/src/main/java/com/github/libretube/adapters/CommentsAdapter.kt @@ -5,18 +5,16 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.Toast -import androidx.constraintlayout.motion.widget.MotionLayout -import androidx.core.os.bundleOf import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.github.libretube.R -import com.github.libretube.activities.MainActivity import com.github.libretube.databinding.CommentsRowBinding import com.github.libretube.obj.Comment import com.github.libretube.obj.CommentsPage +import com.github.libretube.util.ConnectionHelper +import com.github.libretube.util.NavigationHelper import com.github.libretube.util.RetrofitInstance import com.github.libretube.util.formatShort -import com.squareup.picasso.Picasso import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -53,7 +51,7 @@ class CommentsAdapter( " • " + comment.commentedTime.toString() commentText.text = comment.commentText.toString() - Picasso.get().load(comment.thumbnail).fit().centerCrop().into(commentorImage) + ConnectionHelper.loadImage(comment.thumbnail, commentorImage) likesTextView.text = comment.likeCount?.toLong().formatShort() if (comment.verified == true) { @@ -66,19 +64,7 @@ class CommentsAdapter( heartedImageView.visibility = View.VISIBLE } commentorImage.setOnClickListener { - val activity = root.context as MainActivity - val bundle = bundleOf("channel_id" to comment.commentorUrl) - activity.navController.navigate(R.id.channelFragment, bundle) - try { - val mainMotionLayout = - activity.findViewById(R.id.mainMotionLayout) - if (mainMotionLayout.progress == 0.toFloat()) { - mainMotionLayout.transitionToEnd() - activity.findViewById(R.id.playerMotionLayout) - .transitionToEnd() - } - } catch (e: Exception) { - } + NavigationHelper.navigateChannel(root.context, comment.commentorUrl) } repliesRecView.layoutManager = LinearLayoutManager(root.context) val repliesAdapter = RepliesAdapter(CommentsPage().comments) diff --git a/app/src/main/java/com/github/libretube/adapters/PlaylistAdapter.kt b/app/src/main/java/com/github/libretube/adapters/PlaylistAdapter.kt index d6ec9bea0..67db0cdcc 100644 --- a/app/src/main/java/com/github/libretube/adapters/PlaylistAdapter.kt +++ b/app/src/main/java/com/github/libretube/adapters/PlaylistAdapter.kt @@ -1,24 +1,23 @@ package com.github.libretube.adapters import android.app.Activity -import android.os.Bundle -import android.text.format.DateUtils import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.FragmentManager import androidx.recyclerview.widget.RecyclerView -import com.github.libretube.R import com.github.libretube.databinding.PlaylistRowBinding import com.github.libretube.dialogs.VideoOptionsDialog -import com.github.libretube.fragments.PlayerFragment +import com.github.libretube.extensions.setFormattedDuration import com.github.libretube.obj.PlaylistId import com.github.libretube.obj.StreamItem import com.github.libretube.preferences.PreferenceHelper +import com.github.libretube.util.ConnectionHelper +import com.github.libretube.util.NavigationHelper import com.github.libretube.util.RetrofitInstance -import com.squareup.picasso.Picasso +import com.github.libretube.util.setWatchProgressLength +import com.github.libretube.util.toID import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -54,69 +53,46 @@ class PlaylistAdapter( holder.binding.apply { playlistTitle.text = streamItem.title playlistDescription.text = streamItem.uploaderName - playlistDuration.text = DateUtils.formatElapsedTime(streamItem.duration!!) - Picasso.get().load(streamItem.thumbnail).into(playlistThumbnail) + thumbnailDuration.setFormattedDuration(streamItem.duration!!) + ConnectionHelper.loadImage(streamItem.thumbnail, playlistThumbnail) root.setOnClickListener { - var bundle = Bundle() - bundle.putString("videoId", streamItem.url!!.replace("/watch?v=", "")) - bundle.putString("playlistId", playlistId) - var frag = PlayerFragment() - frag.arguments = bundle - val activity = root.context as AppCompatActivity - activity.supportFragmentManager.beginTransaction() - .remove(PlayerFragment()) - .commit() - activity.supportFragmentManager.beginTransaction() - .replace(R.id.container, frag) - .commitNow() + NavigationHelper.navigateVideo(root.context, streamItem.url, playlistId) } + val videoId = streamItem.url.toID() root.setOnLongClickListener { - val videoId = streamItem.url!!.replace("/watch?v=", "") - VideoOptionsDialog(videoId, root.context) - .show(childFragmentManager, VideoOptionsDialog.TAG) + VideoOptionsDialog(videoId) + .show(childFragmentManager, VideoOptionsDialog::class.java.name) true } if (isOwner) { deletePlaylist.visibility = View.VISIBLE deletePlaylist.setOnClickListener { - val token = PreferenceHelper.getToken(root.context) - removeFromPlaylist(token, position) + removeFromPlaylist(position) } } + watchProgress.setWatchProgressLength(videoId, streamItem.duration!!) } } - private fun removeFromPlaylist(token: String, position: Int) { - fun run() { - CoroutineScope(Dispatchers.IO).launch { - val response = try { - RetrofitInstance.authApi.removeFromPlaylist( - token, - PlaylistId(playlistId = playlistId, index = position) - ) - } catch (e: IOException) { - println(e) - Log.e(TAG, "IOException, you might not have internet connection") - return@launch - } catch (e: HttpException) { - Log.e(TAG, "HttpException, unexpected response") - return@launch - } finally { - } - try { - if (response.message == "ok") { - Log.d(TAG, "deleted!") - videoFeed.removeAt(position) - // FIXME: This needs to run on UI thread? - activity.runOnUiThread { notifyDataSetChanged() } - } - } catch (e: Exception) { - Log.e(TAG, e.toString()) - } + fun removeFromPlaylist(position: Int) { + videoFeed.removeAt(position) + activity.runOnUiThread { notifyDataSetChanged() } + CoroutineScope(Dispatchers.IO).launch { + try { + RetrofitInstance.authApi.removeFromPlaylist( + PreferenceHelper.getToken(), + PlaylistId(playlistId = playlistId, index = position) + ) + } catch (e: IOException) { + println(e) + Log.e(TAG, "IOException, you might not have internet connection") + return@launch + } catch (e: HttpException) { + Log.e(TAG, "HttpException, unexpected response") + return@launch } } - run() } } diff --git a/app/src/main/java/com/github/libretube/adapters/PlaylistsAdapter.kt b/app/src/main/java/com/github/libretube/adapters/PlaylistsAdapter.kt index 7818583e9..37e633215 100644 --- a/app/src/main/java/com/github/libretube/adapters/PlaylistsAdapter.kt +++ b/app/src/main/java/com/github/libretube/adapters/PlaylistsAdapter.kt @@ -4,17 +4,18 @@ import android.app.Activity import android.util.Log import android.view.LayoutInflater import android.view.ViewGroup -import androidx.core.os.bundleOf +import androidx.fragment.app.FragmentManager import androidx.recyclerview.widget.RecyclerView import com.github.libretube.R -import com.github.libretube.activities.MainActivity import com.github.libretube.databinding.PlaylistsRowBinding +import com.github.libretube.dialogs.PlaylistOptionsDialog import com.github.libretube.obj.PlaylistId import com.github.libretube.obj.Playlists import com.github.libretube.preferences.PreferenceHelper +import com.github.libretube.util.ConnectionHelper +import com.github.libretube.util.NavigationHelper import com.github.libretube.util.RetrofitInstance import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.squareup.picasso.Picasso import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -23,6 +24,7 @@ import java.io.IOException class PlaylistsAdapter( private val playlists: MutableList, + private val childFragmentManager: FragmentManager, private val activity: Activity ) : RecyclerView.Adapter() { val TAG = "PlaylistsAdapter" @@ -45,11 +47,12 @@ class PlaylistsAdapter( override fun onBindViewHolder(holder: PlaylistsViewHolder, position: Int) { val playlist = playlists[position] holder.binding.apply { - Picasso.get().load(playlist.thumbnail).into(playlistThumbnail) // set imageview drawable as empty playlist if imageview empty - if (playlistThumbnail.drawable == null) { + if (playlist.thumbnail!!.split("/").size <= 4) { playlistThumbnail.setImageResource(R.drawable.ic_empty_playlist) playlistThumbnail.setBackgroundColor(R.attr.colorSurface) + } else { + ConnectionHelper.loadImage(playlist.thumbnail, playlistThumbnail) } playlistTitle.text = playlist.name deletePlaylist.setOnClickListener { @@ -57,27 +60,38 @@ class PlaylistsAdapter( builder.setTitle(R.string.deletePlaylist) builder.setMessage(R.string.areYouSure) builder.setPositiveButton(R.string.yes) { _, _ -> - val token = PreferenceHelper.getToken(root.context) - deletePlaylist(playlist.id!!, token, position) - } - builder.setNegativeButton(R.string.cancel) { _, _ -> + PreferenceHelper.getToken() + deletePlaylist(playlist.id!!, position) } + builder.setNegativeButton(R.string.cancel, null) builder.show() } root.setOnClickListener { - // playlists clicked - val activity = root.context as MainActivity - val bundle = bundleOf("playlist_id" to playlist.id) - activity.navController.navigate(R.id.playlistFragment, bundle) + NavigationHelper.navigatePlaylist(root.context, playlist.id, true) + } + + root.setOnLongClickListener { + val playlistOptionsDialog = PlaylistOptionsDialog( + playlistId = playlist.id!!, + isOwner = true + ) + playlistOptionsDialog.show( + childFragmentManager, + PlaylistOptionsDialog::class.java.name + ) + true } } } - private fun deletePlaylist(id: String, token: String, position: Int) { + private fun deletePlaylist(id: String, position: Int) { fun run() { CoroutineScope(Dispatchers.IO).launch { val response = try { - RetrofitInstance.authApi.deletePlaylist(token, PlaylistId(id)) + RetrofitInstance.authApi.deletePlaylist( + PreferenceHelper.getToken(), + PlaylistId(id) + ) } catch (e: IOException) { println(e) Log.e(TAG, "IOException, you might not have internet connection") @@ -88,9 +102,7 @@ class PlaylistsAdapter( } try { if (response.message == "ok") { - Log.d(TAG, "deleted!") playlists.removeAt(position) - // FIXME: This needs to run on UI thread? activity.runOnUiThread { notifyDataSetChanged() } } } catch (e: Exception) { diff --git a/app/src/main/java/com/github/libretube/adapters/RepliesAdapter.kt b/app/src/main/java/com/github/libretube/adapters/RepliesAdapter.kt index d53429a03..b76fc5b87 100644 --- a/app/src/main/java/com/github/libretube/adapters/RepliesAdapter.kt +++ b/app/src/main/java/com/github/libretube/adapters/RepliesAdapter.kt @@ -3,15 +3,12 @@ package com.github.libretube.adapters import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.constraintlayout.motion.widget.MotionLayout -import androidx.core.os.bundleOf import androidx.recyclerview.widget.RecyclerView -import com.github.libretube.R -import com.github.libretube.activities.MainActivity import com.github.libretube.databinding.RepliesRowBinding import com.github.libretube.obj.Comment +import com.github.libretube.util.ConnectionHelper +import com.github.libretube.util.NavigationHelper import com.github.libretube.util.formatShort -import com.squareup.picasso.Picasso class RepliesAdapter( private val replies: MutableList @@ -44,7 +41,7 @@ class RepliesAdapter( " • " + reply.commentedTime.toString() commentText.text = reply.commentText.toString() - Picasso.get().load(reply.thumbnail).fit().centerCrop().into(commentorImage) + ConnectionHelper.loadImage(reply.thumbnail, commentorImage) likesTextView.text = reply.likeCount?.toLong().formatShort() if (reply.verified == true) { @@ -57,19 +54,7 @@ class RepliesAdapter( heartedImageView.visibility = View.VISIBLE } commentorImage.setOnClickListener { - val activity = root.context as MainActivity - val bundle = bundleOf("channel_id" to reply.commentorUrl) - activity.navController.navigate(R.id.channelFragment, bundle) - try { - val mainMotionLayout = - activity.findViewById(R.id.mainMotionLayout) - if (mainMotionLayout.progress == 0.toFloat()) { - mainMotionLayout.transitionToEnd() - activity.findViewById(R.id.playerMotionLayout) - .transitionToEnd() - } - } catch (e: Exception) { - } + NavigationHelper.navigateVideo(root.context, reply.commentorUrl) } } } diff --git a/app/src/main/java/com/github/libretube/adapters/SearchAdapter.kt b/app/src/main/java/com/github/libretube/adapters/SearchAdapter.kt index 29a14e39d..62213ab41 100644 --- a/app/src/main/java/com/github/libretube/adapters/SearchAdapter.kt +++ b/app/src/main/java/com/github/libretube/adapters/SearchAdapter.kt @@ -1,32 +1,27 @@ package com.github.libretube.adapters -import android.os.Bundle -import android.text.format.DateUtils import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.appcompat.app.AppCompatActivity -import androidx.core.os.bundleOf import androidx.fragment.app.FragmentManager import androidx.recyclerview.widget.RecyclerView import com.github.libretube.R -import com.github.libretube.activities.MainActivity -import com.github.libretube.databinding.ChannelSearchRowBinding +import com.github.libretube.databinding.ChannelRowBinding import com.github.libretube.databinding.PlaylistSearchRowBinding -import com.github.libretube.databinding.VideoSearchRowBinding +import com.github.libretube.databinding.VideoRowBinding import com.github.libretube.dialogs.PlaylistOptionsDialog import com.github.libretube.dialogs.VideoOptionsDialog -import com.github.libretube.fragments.PlayerFragment +import com.github.libretube.extensions.setFormattedDuration import com.github.libretube.obj.SearchItem -import com.github.libretube.obj.Subscribe -import com.github.libretube.preferences.PreferenceHelper -import com.github.libretube.util.RetrofitInstance +import com.github.libretube.util.ConnectionHelper +import com.github.libretube.util.NavigationHelper +import com.github.libretube.util.SubscriptionHelper import com.github.libretube.util.formatShort -import com.squareup.picasso.Picasso +import com.github.libretube.util.setWatchProgressLength +import com.github.libretube.util.toID import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import java.io.IOException class SearchAdapter( private val searchItems: MutableList, @@ -49,10 +44,10 @@ class SearchAdapter( return when (viewType) { 0 -> SearchViewHolder( - VideoSearchRowBinding.inflate(layoutInflater, parent, false) + VideoRowBinding.inflate(layoutInflater, parent, false) ) 1 -> SearchViewHolder( - ChannelSearchRowBinding.inflate(layoutInflater, parent, false) + ChannelRowBinding.inflate(layoutInflater, parent, false) ) 2 -> SearchViewHolder( PlaylistSearchRowBinding.inflate(layoutInflater, parent, false) @@ -82,161 +77,101 @@ class SearchAdapter( } } - private fun bindWatch(item: SearchItem, binding: VideoSearchRowBinding) { + private fun bindWatch(item: SearchItem, binding: VideoRowBinding) { binding.apply { - Picasso.get().load(item.thumbnail).fit().centerCrop().into(searchThumbnail) - if (item.duration != -1L) { - searchThumbnailDuration.text = DateUtils.formatElapsedTime(item.duration!!) - } else { - searchThumbnailDuration.text = root.context.getString(R.string.live) - searchThumbnailDuration.setBackgroundColor(R.attr.colorPrimaryDark) - } - Picasso.get().load(item.uploaderAvatar).fit().centerCrop().into(searchChannelImage) - searchDescription.text = item.title + ConnectionHelper.loadImage(item.thumbnail, thumbnail) + thumbnailDuration.setFormattedDuration(item.duration!!) + ConnectionHelper.loadImage(item.uploaderAvatar, channelImage) + videoTitle.text = item.title val viewsString = if (item.views?.toInt() != -1) item.views.formatShort() else "" val uploadDate = if (item.uploadedDate != null) item.uploadedDate else "" - searchViews.text = + videoInfo.text = if (viewsString != "" && uploadDate != "") { "$viewsString • $uploadDate" } else { viewsString + uploadDate } - searchChannelName.text = item.uploaderName + channelName.text = item.uploaderName root.setOnClickListener { - val bundle = Bundle() - bundle.putString("videoId", item.url!!.replace("/watch?v=", "")) - val frag = PlayerFragment() - frag.arguments = bundle - val activity = root.context as AppCompatActivity - activity.supportFragmentManager.beginTransaction() - .remove(PlayerFragment()) - .commit() - activity.supportFragmentManager.beginTransaction() - .replace(R.id.container, frag) - .commitNow() + NavigationHelper.navigateVideo(root.context, item.url) } + val videoId = item.url.toID() root.setOnLongClickListener { - val videoId = item.url!!.replace("/watch?v=", "") - VideoOptionsDialog(videoId, root.context) - .show(childFragmentManager, VideoOptionsDialog.TAG) + VideoOptionsDialog(videoId) + .show(childFragmentManager, VideoOptionsDialog::class.java.name) true } - searchChannelImage.setOnClickListener { - val activity = root.context as MainActivity - val bundle = bundleOf("channel_id" to item.uploaderUrl) - activity.navController.navigate(R.id.channelFragment, bundle) + channelImage.setOnClickListener { + NavigationHelper.navigateChannel(root.context, item.uploaderUrl) } + watchProgress.setWatchProgressLength(videoId, item.duration!!) } } - private fun bindChannel(item: SearchItem, binding: ChannelSearchRowBinding) { + private fun bindChannel(item: SearchItem, binding: ChannelRowBinding) { binding.apply { - Picasso.get().load(item.thumbnail).fit().centerCrop().into(searchChannelImage) + ConnectionHelper.loadImage(item.thumbnail, searchChannelImage) searchChannelName.text = item.name searchViews.text = root.context.getString( R.string.subscribers, item.subscribers.formatShort() ) + " • " + root.context.getString(R.string.videoCount, item.videos.toString()) root.setOnClickListener { - val activity = root.context as MainActivity - val bundle = bundleOf("channel_id" to item.url) - activity.navController.navigate(R.id.channelFragment, bundle) + NavigationHelper.navigateChannel(root.context, item.url) } - val channelId = item.url?.replace("/channel/", "")!! - val token = PreferenceHelper.getToken(root.context) + val channelId = item.url.toID() - // only show subscribe button if logged in - if (token != "") isSubscribed(channelId, token, binding) + isSubscribed(channelId, binding) } } - private fun isSubscribed(channelId: String, token: String, binding: ChannelSearchRowBinding) { - var isSubscribed = false - + private fun isSubscribed(channelId: String, binding: ChannelRowBinding) { // check whether the user subscribed to the channel CoroutineScope(Dispatchers.Main).launch { - val response = try { - RetrofitInstance.authApi.isSubscribed( - channelId, - token - ) - } catch (e: Exception) { - return@launch - } + var isSubscribed = SubscriptionHelper.isSubscribed(channelId) // if subscribed change text to unsubscribe - if (response.subscribed == true) { - isSubscribed = true + if (isSubscribed == true) { binding.searchSubButton.text = binding.root.context.getString(R.string.unsubscribe) } // make sub button visible and set the on click listeners to (un)subscribe - if (response.subscribed != null) { - binding.searchSubButton.visibility = View.VISIBLE + if (isSubscribed == null) return@launch + binding.searchSubButton.visibility = View.VISIBLE - binding.searchSubButton.setOnClickListener { - if (!isSubscribed) { - subscribe(token, channelId) - binding.searchSubButton.text = - binding.root.context.getString(R.string.unsubscribe) - isSubscribed = true - } else { - unsubscribe(token, channelId) - binding.searchSubButton.text = - binding.root.context.getString(R.string.subscribe) - isSubscribed = false - } + binding.searchSubButton.setOnClickListener { + if (isSubscribed == false) { + SubscriptionHelper.subscribe(channelId) + binding.searchSubButton.text = + binding.root.context.getString(R.string.unsubscribe) + isSubscribed = true + } else { + SubscriptionHelper.unsubscribe(channelId) + binding.searchSubButton.text = + binding.root.context.getString(R.string.subscribe) + isSubscribed = false } } } } - private fun subscribe(token: String, channelId: String) { - CoroutineScope(Dispatchers.IO).launch { - try { - RetrofitInstance.authApi.subscribe( - token, - Subscribe(channelId) - ) - } catch (e: Exception) { - return@launch - } - } - } - - private fun unsubscribe(token: String, channelId: String) { - CoroutineScope(Dispatchers.IO).launch { - try { - RetrofitInstance.authApi.unsubscribe( - token, - Subscribe(channelId) - ) - } catch (e: IOException) { - return@launch - } - } - } - private fun bindPlaylist(item: SearchItem, binding: PlaylistSearchRowBinding) { binding.apply { - Picasso.get().load(item.thumbnail).fit().centerCrop().into(searchThumbnail) + ConnectionHelper.loadImage(item.thumbnail, searchThumbnail) if (item.videos?.toInt() != -1) searchPlaylistNumber.text = item.videos.toString() searchDescription.text = item.name searchName.text = item.uploaderName if (item.videos?.toInt() != -1) { - searchPlaylistNumber.text = + searchPlaylistVideos.text = root.context.getString(R.string.videoCount, item.videos.toString()) } root.setOnClickListener { - // playlist clicked - val activity = root.context as MainActivity - val bundle = bundleOf("playlist_id" to item.url) - activity.navController.navigate(R.id.playlistFragment, bundle) + NavigationHelper.navigatePlaylist(root.context, item.url, false) } root.setOnLongClickListener { - val playlistId = item.url!!.replace("/playlist?list=", "") - PlaylistOptionsDialog(playlistId, false, root.context) - .show(childFragmentManager, "PlaylistOptionsDialog") + val playlistId = item.url!!.toID() + PlaylistOptionsDialog(playlistId, false) + .show(childFragmentManager, PlaylistOptionsDialog::class.java.name) true } } @@ -244,15 +179,15 @@ class SearchAdapter( } class SearchViewHolder : RecyclerView.ViewHolder { - var videoRowBinding: VideoSearchRowBinding? = null - var channelRowBinding: ChannelSearchRowBinding? = null + var videoRowBinding: VideoRowBinding? = null + var channelRowBinding: ChannelRowBinding? = null var playlistRowBinding: PlaylistSearchRowBinding? = null - constructor(binding: VideoSearchRowBinding) : super(binding.root) { + constructor(binding: VideoRowBinding) : super(binding.root) { videoRowBinding = binding } - constructor(binding: ChannelSearchRowBinding) : super(binding.root) { + constructor(binding: ChannelRowBinding) : super(binding.root) { channelRowBinding = binding } diff --git a/app/src/main/java/com/github/libretube/adapters/SearchHistoryAdapter.kt b/app/src/main/java/com/github/libretube/adapters/SearchHistoryAdapter.kt index 994e4caf4..bc3ac4384 100644 --- a/app/src/main/java/com/github/libretube/adapters/SearchHistoryAdapter.kt +++ b/app/src/main/java/com/github/libretube/adapters/SearchHistoryAdapter.kt @@ -1,19 +1,15 @@ package com.github.libretube.adapters -import android.content.Context import android.view.LayoutInflater import android.view.ViewGroup -import android.widget.EditText +import androidx.appcompat.widget.SearchView import androidx.recyclerview.widget.RecyclerView import com.github.libretube.databinding.SearchhistoryRowBinding -import com.github.libretube.fragments.SearchFragment import com.github.libretube.preferences.PreferenceHelper class SearchHistoryAdapter( - private val context: Context, private var historyList: List, - private val editText: EditText, - private val searchFragment: SearchFragment + private val searchView: SearchView ) : RecyclerView.Adapter() { @@ -28,19 +24,18 @@ class SearchHistoryAdapter( } override fun onBindViewHolder(holder: SearchHistoryViewHolder, position: Int) { - val history = historyList[position] + val historyQuery = historyList[position] holder.binding.apply { - historyText.text = history + historyText.text = historyQuery deleteHistory.setOnClickListener { - historyList = historyList - history - PreferenceHelper.saveHistory(context, historyList) + historyList = historyList - historyQuery + PreferenceHelper.removeFromSearchHistory(historyQuery) notifyDataSetChanged() } root.setOnClickListener { - editText.setText(history) - searchFragment.fetchSearch(history) + searchView.setQuery(historyQuery, true) } } } diff --git a/app/src/main/java/com/github/libretube/adapters/SearchSuggestionsAdapter.kt b/app/src/main/java/com/github/libretube/adapters/SearchSuggestionsAdapter.kt index fa751abb1..8ab6014c2 100644 --- a/app/src/main/java/com/github/libretube/adapters/SearchSuggestionsAdapter.kt +++ b/app/src/main/java/com/github/libretube/adapters/SearchSuggestionsAdapter.kt @@ -2,15 +2,13 @@ package com.github.libretube.adapters import android.view.LayoutInflater import android.view.ViewGroup -import android.widget.EditText +import androidx.appcompat.widget.SearchView import androidx.recyclerview.widget.RecyclerView import com.github.libretube.databinding.SearchsuggestionRowBinding -import com.github.libretube.fragments.SearchFragment class SearchSuggestionsAdapter( private var suggestionsList: List, - private var editText: EditText, - private val searchFragment: SearchFragment + private val searchView: SearchView ) : RecyclerView.Adapter() { @@ -31,8 +29,7 @@ class SearchSuggestionsAdapter( holder.binding.apply { suggestionText.text = suggestion root.setOnClickListener { - editText.setText(suggestion) - searchFragment.fetchSearch(editText.text.toString()) + searchView.setQuery(suggestion, true) } } } diff --git a/app/src/main/java/com/github/libretube/adapters/SubscriptionAdapter.kt b/app/src/main/java/com/github/libretube/adapters/SubscriptionAdapter.kt deleted file mode 100644 index 9ba5513e7..000000000 --- a/app/src/main/java/com/github/libretube/adapters/SubscriptionAdapter.kt +++ /dev/null @@ -1,101 +0,0 @@ -package com.github.libretube.adapters - -import android.os.Bundle -import android.text.format.DateUtils -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.appcompat.app.AppCompatActivity -import androidx.constraintlayout.motion.widget.MotionLayout -import androidx.core.os.bundleOf -import androidx.fragment.app.FragmentManager -import androidx.recyclerview.widget.RecyclerView -import com.github.libretube.R -import com.github.libretube.activities.MainActivity -import com.github.libretube.databinding.TrendingRowBinding -import com.github.libretube.dialogs.VideoOptionsDialog -import com.github.libretube.fragments.PlayerFragment -import com.github.libretube.obj.StreamItem -import com.github.libretube.util.formatShort -import com.squareup.picasso.Picasso - -class SubscriptionAdapter( - private val videoFeed: List, - private val childFragmentManager: FragmentManager -) : RecyclerView.Adapter() { - private val TAG = "SubscriptionAdapter" - - var i = 0 - override fun getItemCount(): Int { - return i - } - - fun updateItems() { - i += 10 - if (i > videoFeed.size) { - i = videoFeed.size - } - notifyDataSetChanged() - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SubscriptionViewHolder { - val layoutInflater = LayoutInflater.from(parent.context) - val binding = TrendingRowBinding.inflate(layoutInflater, parent, false) - return SubscriptionViewHolder(binding) - } - - override fun onBindViewHolder(holder: SubscriptionViewHolder, position: Int) { - val trending = videoFeed[position] - holder.binding.apply { - textViewTitle.text = trending.title - textViewChannel.text = - trending.uploaderName + " • " + - trending.views.formatShort() + " • " + - DateUtils.getRelativeTimeSpanString(trending.uploaded!!) - if (trending.duration != -1L) { - thumbnailDuration.text = DateUtils.formatElapsedTime(trending.duration!!) - } else { - thumbnailDuration.text = root.context.getString(R.string.live) - thumbnailDuration.setBackgroundColor(R.attr.colorPrimaryDark) - } - channelImage.setOnClickListener { - val activity = root.context as MainActivity - val bundle = bundleOf("channel_id" to trending.uploaderUrl) - activity.navController.navigate(R.id.channelFragment, bundle) - try { - val mainMotionLayout = - activity.findViewById(R.id.mainMotionLayout) - if (mainMotionLayout.progress == 0.toFloat()) { - mainMotionLayout.transitionToEnd() - activity.findViewById(R.id.playerMotionLayout) - .transitionToEnd() - } - } catch (e: Exception) { - } - } - Picasso.get().load(trending.thumbnail).into(thumbnail) - Picasso.get().load(trending.uploaderAvatar).into(channelImage) - root.setOnClickListener { - val bundle = Bundle() - bundle.putString("videoId", trending.url!!.replace("/watch?v=", "")) - val frag = PlayerFragment() - frag.arguments = bundle - val activity = root.context as AppCompatActivity - activity.supportFragmentManager.beginTransaction() - .remove(PlayerFragment()) - .commit() - activity.supportFragmentManager.beginTransaction() - .replace(R.id.container, frag) - .commitNow() - } - root.setOnLongClickListener { - val videoId = trending.url!!.replace("/watch?v=", "") - VideoOptionsDialog(videoId, root.context) - .show(childFragmentManager, VideoOptionsDialog.TAG) - true - } - } - } -} - -class SubscriptionViewHolder(val binding: TrendingRowBinding) : - RecyclerView.ViewHolder(binding.root) diff --git a/app/src/main/java/com/github/libretube/adapters/SubscriptionChannelAdapter.kt b/app/src/main/java/com/github/libretube/adapters/SubscriptionChannelAdapter.kt index 6def52cb1..ef2ff27a1 100644 --- a/app/src/main/java/com/github/libretube/adapters/SubscriptionChannelAdapter.kt +++ b/app/src/main/java/com/github/libretube/adapters/SubscriptionChannelAdapter.kt @@ -1,30 +1,20 @@ package com.github.libretube.adapters -import android.content.Context -import android.util.Log import android.view.LayoutInflater import android.view.ViewGroup -import androidx.core.os.bundleOf import androidx.recyclerview.widget.RecyclerView import com.github.libretube.R -import com.github.libretube.activities.MainActivity import com.github.libretube.databinding.ChannelSubscriptionRowBinding -import com.github.libretube.obj.Subscribe import com.github.libretube.obj.Subscription -import com.github.libretube.preferences.PreferenceHelper -import com.github.libretube.util.RetrofitInstance -import com.squareup.picasso.Picasso -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch +import com.github.libretube.util.ConnectionHelper +import com.github.libretube.util.NavigationHelper +import com.github.libretube.util.SubscriptionHelper +import com.github.libretube.util.toID class SubscriptionChannelAdapter(private val subscriptions: MutableList) : RecyclerView.Adapter() { val TAG = "SubChannelAdapter" - private var subscribed = true - private var isLoading = false - override fun getItemCount(): Int { return subscriptions.size } @@ -38,68 +28,29 @@ class SubscriptionChannelAdapter(private val subscriptions: MutableList, - private val childFragmentManager: FragmentManager -) : RecyclerView.Adapter() { + private val streamItems: List, + private val childFragmentManager: FragmentManager, + private val showAllAtOne: Boolean = true +) : RecyclerView.Adapter() { private val TAG = "TrendingAdapter" + var index = 10 + override fun getItemCount(): Int { - return videoFeed.size + return if (showAllAtOne) streamItems.size + else if (index >= streamItems.size) streamItems.size - 1 + else index } - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TrendingViewHolder { + fun updateItems() { + index += 10 + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SubscriptionViewHolder { val layoutInflater = LayoutInflater.from(parent.context) val binding = TrendingRowBinding.inflate(layoutInflater, parent, false) - return TrendingViewHolder(binding) + return SubscriptionViewHolder(binding) } - override fun onBindViewHolder(holder: TrendingViewHolder, position: Int) { - val trending = videoFeed[position] + override fun onBindViewHolder(holder: SubscriptionViewHolder, position: Int) { + val trending = streamItems[position] holder.binding.apply { textViewTitle.text = trending.title textViewChannel.text = trending.uploaderName + " • " + trending.views.formatShort() + " • " + DateUtils.getRelativeTimeSpanString(trending.uploaded!!) - if (trending.duration != -1L) { - thumbnailDuration.text = DateUtils.formatElapsedTime(trending.duration!!) - } else { - thumbnailDuration.text = root.context.getString(R.string.live) - thumbnailDuration.setBackgroundColor(R.attr.colorPrimaryDark) - } + thumbnailDuration.setFormattedDuration(trending.duration!!) channelImage.setOnClickListener { - val activity = root.context as MainActivity - val bundle = bundleOf("channel_id" to trending.uploaderUrl) - activity.navController.navigate(R.id.channelFragment, bundle) - try { - val mainMotionLayout = - activity.findViewById(R.id.mainMotionLayout) - if (mainMotionLayout.progress == 0.toFloat()) { - mainMotionLayout.transitionToEnd() - activity.findViewById(R.id.playerMotionLayout) - .transitionToEnd() - } - } catch (e: Exception) { - } + NavigationHelper.navigateChannel(root.context, trending.uploaderUrl) } - if (trending.thumbnail!!.isNotEmpty()) { - Picasso.get().load(trending.thumbnail).into(thumbnail) - } - if (trending.uploaderAvatar!!.isNotEmpty()) { - Picasso.get().load(trending.uploaderAvatar).into(channelImage) - } - + ConnectionHelper.loadImage(trending.thumbnail, thumbnail) + ConnectionHelper.loadImage(trending.uploaderAvatar, channelImage) root.setOnClickListener { - var bundle = Bundle() - bundle.putString("videoId", trending.url!!.replace("/watch?v=", "")) - var frag = PlayerFragment() - frag.arguments = bundle - val activity = root.context as AppCompatActivity - activity.supportFragmentManager.beginTransaction() - .remove(PlayerFragment()) - .commit() - activity.supportFragmentManager.beginTransaction() - .replace(R.id.container, frag) - .commitNow() + NavigationHelper.navigateVideo(root.context, trending.url) } + val videoId = trending.url!!.toID() root.setOnLongClickListener { - val videoId = trending.url!!.replace("/watch?v=", "") - VideoOptionsDialog(videoId, root.context) - .show(childFragmentManager, VideoOptionsDialog.TAG) + VideoOptionsDialog(videoId) + .show(childFragmentManager, VideoOptionsDialog::class.java.name) true } + watchProgress.setWatchProgressLength(videoId, trending.duration!!) } } } -class TrendingViewHolder(val binding: TrendingRowBinding) : RecyclerView.ViewHolder(binding.root) +class SubscriptionViewHolder(val binding: TrendingRowBinding) : + RecyclerView.ViewHolder(binding.root) diff --git a/app/src/main/java/com/github/libretube/adapters/WatchHistoryAdapter.kt b/app/src/main/java/com/github/libretube/adapters/WatchHistoryAdapter.kt index 07ed2e388..f6a63f981 100644 --- a/app/src/main/java/com/github/libretube/adapters/WatchHistoryAdapter.kt +++ b/app/src/main/java/com/github/libretube/adapters/WatchHistoryAdapter.kt @@ -1,21 +1,17 @@ package com.github.libretube.adapters -import android.os.Bundle -import android.text.format.DateUtils import android.view.LayoutInflater import android.view.ViewGroup -import androidx.appcompat.app.AppCompatActivity -import androidx.constraintlayout.motion.widget.MotionLayout -import androidx.core.os.bundleOf import androidx.fragment.app.FragmentManager import androidx.recyclerview.widget.RecyclerView -import com.github.libretube.R -import com.github.libretube.activities.MainActivity import com.github.libretube.databinding.WatchHistoryRowBinding import com.github.libretube.dialogs.VideoOptionsDialog -import com.github.libretube.fragments.PlayerFragment +import com.github.libretube.extensions.setFormattedDuration import com.github.libretube.obj.WatchHistoryItem -import com.squareup.picasso.Picasso +import com.github.libretube.preferences.PreferenceHelper +import com.github.libretube.util.ConnectionHelper +import com.github.libretube.util.NavigationHelper +import com.github.libretube.util.setWatchProgressLength class WatchHistoryAdapter( private val watchHistory: MutableList, @@ -24,10 +20,10 @@ class WatchHistoryAdapter( RecyclerView.Adapter() { private val TAG = "WatchHistoryAdapter" - fun clear() { - val size = watchHistory.size - watchHistory.clear() - notifyItemRangeRemoved(0, size) + fun removeFromWatchHistory(position: Int) { + PreferenceHelper.removeFromWatchHistory(position) + watchHistory.removeAt(position) + notifyDataSetChanged() } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): WatchHistoryViewHolder { @@ -41,45 +37,29 @@ class WatchHistoryAdapter( holder.binding.apply { videoTitle.text = video.title channelName.text = video.uploader - uploadDate.text = video.uploadDate - thumbnailDuration.text = DateUtils.formatElapsedTime(video.duration?.toLong()!!) - Picasso.get().load(video.thumbnailUrl).into(thumbnail) - Picasso.get().load(video.uploaderAvatar).into(channelImage) + videoInfo.text = video.uploadDate + thumbnailDuration.setFormattedDuration(video.duration!!) + ConnectionHelper.loadImage(video.thumbnailUrl, thumbnail) + ConnectionHelper.loadImage(video.uploaderAvatar, channelImage) channelImage.setOnClickListener { - val activity = root.context as MainActivity - val bundle = bundleOf("channel_id" to video.uploaderUrl) - activity.navController.navigate(R.id.channelFragment, bundle) - try { - val mainMotionLayout = - activity.findViewById(R.id.mainMotionLayout) - if (mainMotionLayout.progress == 0.toFloat()) { - mainMotionLayout.transitionToEnd() - activity.findViewById(R.id.playerMotionLayout) - .transitionToEnd() - } - } catch (e: Exception) { - } + NavigationHelper.navigateChannel(root.context, video.uploaderUrl) + } + + deleteBTN.setOnClickListener { + removeFromWatchHistory(position) } root.setOnClickListener { - var bundle = Bundle() - bundle.putString("videoId", video.videoId) - var frag = PlayerFragment() - frag.arguments = bundle - val activity = root.context as AppCompatActivity - activity.supportFragmentManager.beginTransaction() - .remove(PlayerFragment()) - .commit() - activity.supportFragmentManager.beginTransaction() - .replace(R.id.container, frag) - .commitNow() + NavigationHelper.navigateVideo(root.context, video.videoId) } root.setOnLongClickListener { - VideoOptionsDialog(video.videoId!!, root.context) - .show(childFragmentManager, VideoOptionsDialog.TAG) + VideoOptionsDialog(video.videoId!!) + .show(childFragmentManager, VideoOptionsDialog::class.java.name) true } + + watchProgress.setWatchProgressLength(video.videoId!!, video.duration) } } diff --git a/app/src/main/java/com/github/libretube/dialogs/AddtoPlaylistDialog.kt b/app/src/main/java/com/github/libretube/dialogs/AddToPlaylistDialog.kt similarity index 84% rename from app/src/main/java/com/github/libretube/dialogs/AddtoPlaylistDialog.kt rename to app/src/main/java/com/github/libretube/dialogs/AddToPlaylistDialog.kt index 14ab1ef30..772a484c1 100644 --- a/app/src/main/java/com/github/libretube/dialogs/AddtoPlaylistDialog.kt +++ b/app/src/main/java/com/github/libretube/dialogs/AddToPlaylistDialog.kt @@ -8,6 +8,7 @@ import android.widget.Toast import androidx.fragment.app.DialogFragment import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope +import com.github.libretube.Globals import com.github.libretube.R import com.github.libretube.databinding.DialogAddtoplaylistBinding import com.github.libretube.obj.PlaylistId @@ -18,7 +19,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder import retrofit2.HttpException import java.io.IOException -class AddtoPlaylistDialog : DialogFragment() { +class AddToPlaylistDialog : DialogFragment() { private val TAG = "AddToPlaylistDialog" private lateinit var binding: DialogAddtoplaylistBinding @@ -32,7 +33,7 @@ class AddtoPlaylistDialog : DialogFragment() { // Get the layout inflater binding = DialogAddtoplaylistBinding.inflate(layoutInflater) - token = PreferenceHelper.getToken(requireContext()) + token = PreferenceHelper.getToken() if (token != "") fetchPlaylists() @@ -59,24 +60,29 @@ class AddtoPlaylistDialog : DialogFragment() { return@launchWhenCreated } if (response.isNotEmpty()) { - var names = emptyList().toMutableList() - for (playlist in response) { - names.add(playlist.name!!) - } + val names = response.map { it.name } val arrayAdapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, names) arrayAdapter.setDropDownViewResource( android.R.layout.simple_spinner_dropdown_item ) binding.playlistsSpinner.adapter = arrayAdapter + if (Globals.SELECTED_PLAYLIST_ID != null) { + var selectionIndex = 0 + response.forEachIndexed { index, playlist -> + if (playlist.id == Globals.SELECTED_PLAYLIST_ID) selectionIndex = index + } + binding.playlistsSpinner.setSelection(selectionIndex) + } runOnUiThread { binding.addToPlaylist.setOnClickListener { + val index = binding.playlistsSpinner.selectedItemPosition + Globals.SELECTED_PLAYLIST_ID = response[index].id!! addToPlaylist( - response[binding.playlistsSpinner.selectedItemPosition].id!! + response[index].id!! ) } } - } else { } } } diff --git a/app/src/main/java/com/github/libretube/dialogs/CreatePlaylistDialog.kt b/app/src/main/java/com/github/libretube/dialogs/CreatePlaylistDialog.kt index fe316ed02..cd5fb6889 100644 --- a/app/src/main/java/com/github/libretube/dialogs/CreatePlaylistDialog.kt +++ b/app/src/main/java/com/github/libretube/dialogs/CreatePlaylistDialog.kt @@ -33,7 +33,7 @@ class CreatePlaylistDialog : DialogFragment() { dismiss() } - token = PreferenceHelper.getToken(requireContext()) + token = PreferenceHelper.getToken() binding.createNewPlaylist.setOnClickListener { // avoid creating the same playlist multiple times by spamming the button diff --git a/app/src/main/java/com/github/libretube/dialogs/CustomInstanceDialog.kt b/app/src/main/java/com/github/libretube/dialogs/CustomInstanceDialog.kt index 07fd1f043..96773bf35 100644 --- a/app/src/main/java/com/github/libretube/dialogs/CustomInstanceDialog.kt +++ b/app/src/main/java/com/github/libretube/dialogs/CustomInstanceDialog.kt @@ -41,7 +41,7 @@ class CustomInstanceDialog : DialogFragment() { URL(customInstance.apiUrl).toURI() URL(customInstance.frontendUrl).toURI() - PreferenceHelper.saveCustomInstance(requireContext(), customInstance) + PreferenceHelper.saveCustomInstance(customInstance) activity?.recreate() dismiss() } catch (e: Exception) { diff --git a/app/src/main/java/com/github/libretube/dialogs/DeleteAccountDialog.kt b/app/src/main/java/com/github/libretube/dialogs/DeleteAccountDialog.kt index 9cb0ad48a..6accb54a4 100644 --- a/app/src/main/java/com/github/libretube/dialogs/DeleteAccountDialog.kt +++ b/app/src/main/java/com/github/libretube/dialogs/DeleteAccountDialog.kt @@ -45,7 +45,7 @@ class DeleteAccountDialog : DialogFragment() { private fun deleteAccount(password: String) { fun run() { lifecycleScope.launchWhenCreated { - val token = PreferenceHelper.getToken(requireContext()) + val token = PreferenceHelper.getToken() try { RetrofitInstance.authApi.deleteAccount(token, DeleteUserRequest(password)) @@ -57,13 +57,13 @@ class DeleteAccountDialog : DialogFragment() { Toast.makeText(context, R.string.success, Toast.LENGTH_SHORT).show() logout() val restartDialog = RequireRestartDialog() - restartDialog.show(childFragmentManager, "RequireRestartDialog") + restartDialog.show(childFragmentManager, RequireRestartDialog::class.java.name) } } run() } private fun logout() { - PreferenceHelper.setToken(requireContext(), "") + PreferenceHelper.setToken("") } } diff --git a/app/src/main/java/com/github/libretube/dialogs/DownloadDialog.kt b/app/src/main/java/com/github/libretube/dialogs/DownloadDialog.kt index 771bd2c8e..4f2ed94a1 100644 --- a/app/src/main/java/com/github/libretube/dialogs/DownloadDialog.kt +++ b/app/src/main/java/com/github/libretube/dialogs/DownloadDialog.kt @@ -1,24 +1,20 @@ package com.github.libretube.dialogs -import android.Manifest import android.app.Dialog import android.content.Intent -import android.content.pm.PackageManager -import android.os.Build import android.os.Bundle -import android.os.Environment import android.util.Log +import android.view.View import android.widget.ArrayAdapter import android.widget.Toast -import androidx.core.app.ActivityCompat import androidx.core.view.size import androidx.fragment.app.DialogFragment import androidx.lifecycle.lifecycleScope import com.github.libretube.R -import com.github.libretube.activities.MainActivity import com.github.libretube.databinding.DialogDownloadBinding import com.github.libretube.obj.Streams import com.github.libretube.services.DownloadService +import com.github.libretube.util.PermissionHelper import com.github.libretube.util.RetrofitInstance import com.github.libretube.util.ThemeHelper import com.google.android.material.dialog.MaterialAlertDialogBuilder @@ -36,48 +32,25 @@ class DownloadDialog : DialogFragment() { return activity?.let { videoId = arguments?.getString("video_id")!! - val mainActivity = activity as MainActivity val builder = MaterialAlertDialogBuilder(it) binding = DialogDownloadBinding.inflate(layoutInflater) fetchAvailableSources() - // request storage permissions if not granted yet - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - Log.d("myz", "" + Build.VERSION.SDK_INT) - if (!Environment.isExternalStorageManager()) { - ActivityCompat.requestPermissions( - mainActivity, - arrayOf( - Manifest.permission.READ_EXTERNAL_STORAGE, - Manifest.permission.MANAGE_EXTERNAL_STORAGE - ), - 1 - ) // permission request code is just an int - } - } else { - if (ActivityCompat.checkSelfPermission( - requireContext(), - Manifest.permission.READ_EXTERNAL_STORAGE - ) != PackageManager.PERMISSION_GRANTED || - ActivityCompat.checkSelfPermission( - requireContext(), - Manifest.permission.WRITE_EXTERNAL_STORAGE - ) != PackageManager.PERMISSION_GRANTED - ) { - ActivityCompat.requestPermissions( - mainActivity, - arrayOf( - Manifest.permission.READ_EXTERNAL_STORAGE, - Manifest.permission.WRITE_EXTERNAL_STORAGE - ), - 1 - ) - } - } + PermissionHelper.requestReadWrite(requireActivity()) binding.title.text = ThemeHelper.getStyledAppName(requireContext()) + binding.audioRadio.setOnClickListener { + binding.videoSpinner.visibility = View.GONE + binding.audioSpinner.visibility = View.VISIBLE + } + + binding.videoRadio.setOnClickListener { + binding.audioSpinner.visibility = View.GONE + binding.videoSpinner.visibility = View.VISIBLE + } + builder.setView(binding.root) builder.create() } ?: throw IllegalStateException("Activity cannot be null") @@ -155,14 +128,15 @@ class DownloadDialog : DialogFragment() { if (binding.audioSpinner.size >= 1) binding.audioSpinner.setSelection(1) binding.download.setOnClickListener { - val selectedAudioUrl = audioUrl[binding.audioSpinner.selectedItemPosition] - val selectedVideoUrl = vidUrl[binding.videoSpinner.selectedItemPosition] + val selectedAudioUrl = + if (binding.audioRadio.isChecked) audioUrl[binding.audioSpinner.selectedItemPosition] else "" + val selectedVideoUrl = + if (binding.videoRadio.isChecked) vidUrl[binding.videoSpinner.selectedItemPosition] else "" val intent = Intent(context, DownloadService::class.java) - intent.putExtra("videoId", videoId) + intent.putExtra("videoName", streams.title) intent.putExtra("videoUrl", selectedVideoUrl) intent.putExtra("audioUrl", selectedAudioUrl) - intent.putExtra("duration", duration) context?.startService(intent) dismiss() } diff --git a/app/src/main/java/com/github/libretube/dialogs/ErrorDialog.kt b/app/src/main/java/com/github/libretube/dialogs/ErrorDialog.kt new file mode 100644 index 000000000..cd0ac0b01 --- /dev/null +++ b/app/src/main/java/com/github/libretube/dialogs/ErrorDialog.kt @@ -0,0 +1,36 @@ +package com.github.libretube.dialogs + +import android.app.Dialog +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.os.Bundle +import android.widget.Toast +import androidx.fragment.app.DialogFragment +import com.github.libretube.R +import com.github.libretube.preferences.PreferenceHelper +import com.google.android.material.dialog.MaterialAlertDialogBuilder + +class ErrorDialog : DialogFragment() { + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val errorLog = PreferenceHelper.getErrorLog() + // reset the error log + PreferenceHelper.saveErrorLog("") + + return MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.error_occurred) + .setMessage(errorLog) + .setNegativeButton(R.string.okay, null) + .setPositiveButton(R.string.copy) { _, _ -> + /** + * copy the error log to the clipboard + */ + val clipboard: ClipboardManager = + context?.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText(context?.getString(R.string.copied), errorLog) + clipboard.setPrimaryClip(clip) + Toast.makeText(context, R.string.copied, Toast.LENGTH_SHORT).show() + } + .show() + } +} diff --git a/app/src/main/java/com/github/libretube/dialogs/LoginDialog.kt b/app/src/main/java/com/github/libretube/dialogs/LoginDialog.kt index de8a2188c..06704b464 100644 --- a/app/src/main/java/com/github/libretube/dialogs/LoginDialog.kt +++ b/app/src/main/java/com/github/libretube/dialogs/LoginDialog.kt @@ -79,11 +79,10 @@ class LoginDialog : DialogFragment() { Toast.makeText(context, response.error, Toast.LENGTH_SHORT).show() } else if (response.token != null) { Toast.makeText(context, R.string.loggedIn, Toast.LENGTH_SHORT).show() - PreferenceHelper.setToken(requireContext(), response.token!!) - PreferenceHelper.setUsername(requireContext(), login.username!!) - val restartDialog = RequireRestartDialog() - restartDialog.show(parentFragmentManager, "RequireRestartDialog") + PreferenceHelper.setToken(response.token!!) + PreferenceHelper.setUsername(login.username!!) dialog?.dismiss() + activity?.recreate() } } } @@ -112,8 +111,8 @@ class LoginDialog : DialogFragment() { Toast.makeText(context, response.error, Toast.LENGTH_SHORT).show() } else if (response.token != null) { Toast.makeText(context, R.string.registered, Toast.LENGTH_SHORT).show() - PreferenceHelper.setToken(requireContext(), response.token!!) - PreferenceHelper.setUsername(requireContext(), login.username!!) + PreferenceHelper.setToken(response.token!!) + PreferenceHelper.setUsername(login.username!!) dialog?.dismiss() } } diff --git a/app/src/main/java/com/github/libretube/dialogs/LogoutDialog.kt b/app/src/main/java/com/github/libretube/dialogs/LogoutDialog.kt index 77520bd33..3fd153692 100644 --- a/app/src/main/java/com/github/libretube/dialogs/LogoutDialog.kt +++ b/app/src/main/java/com/github/libretube/dialogs/LogoutDialog.kt @@ -19,13 +19,13 @@ class LogoutDialog : DialogFragment() { val builder = MaterialAlertDialogBuilder(it) binding = DialogLogoutBinding.inflate(layoutInflater) - val user = PreferenceHelper.getUsername(requireContext()) + val user = PreferenceHelper.getUsername() binding.user.text = binding.user.text.toString() + " (" + user + ")" binding.logout.setOnClickListener { Toast.makeText(context, R.string.loggedout, Toast.LENGTH_SHORT).show() - PreferenceHelper.setToken(requireContext(), "") + PreferenceHelper.setToken("") dialog?.dismiss() activity?.recreate() } diff --git a/app/src/main/java/com/github/libretube/dialogs/PlaylistOptionsDialog.kt b/app/src/main/java/com/github/libretube/dialogs/PlaylistOptionsDialog.kt index bd2124123..eb3750487 100644 --- a/app/src/main/java/com/github/libretube/dialogs/PlaylistOptionsDialog.kt +++ b/app/src/main/java/com/github/libretube/dialogs/PlaylistOptionsDialog.kt @@ -1,7 +1,6 @@ package com.github.libretube.dialogs import android.app.Dialog -import android.content.Context import android.os.Bundle import android.util.Log import android.widget.ArrayAdapter @@ -10,27 +9,31 @@ import androidx.fragment.app.DialogFragment import com.github.libretube.R import com.github.libretube.obj.PlaylistId import com.github.libretube.preferences.PreferenceHelper +import com.github.libretube.util.BackgroundHelper import com.github.libretube.util.RetrofitInstance +import com.github.libretube.util.toID import com.google.android.material.dialog.MaterialAlertDialogBuilder import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import retrofit2.HttpException import java.io.IOException class PlaylistOptionsDialog( private val playlistId: String, - private val isOwner: Boolean, - context: Context + private val isOwner: Boolean ) : DialogFragment() { val TAG = "PlaylistOptionsDialog" - private var optionsList = listOf( - context.getString(R.string.clonePlaylist), - context.getString(R.string.share) - ) - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + // options for the dialog + var optionsList = listOf( + context?.getString(R.string.playOnBackground), + context?.getString(R.string.clonePlaylist), + context?.getString(R.string.share) + ) + if (isOwner) { optionsList = optionsList + context?.getString(R.string.deletePlaylist)!! - @@ -49,9 +52,22 @@ class PlaylistOptionsDialog( ) ) { _, which -> when (optionsList[which]) { + // play the playlist in the background + context?.getString(R.string.playOnBackground) -> { + runBlocking { + val playlist = + if (isOwner) RetrofitInstance.authApi.getPlaylist(playlistId) + else RetrofitInstance.api.getPlaylist(playlistId) + BackgroundHelper.playOnBackground( + context = requireContext(), + videoId = playlist.relatedStreams!![0].url.toID(), + playlistId = playlistId + ) + } + } // Clone the playlist to the users Piped account context?.getString(R.string.clonePlaylist) -> { - val token = PreferenceHelper.getToken(requireContext()) + val token = PreferenceHelper.getToken() if (token != "") { importPlaylist(token, playlistId) } else { @@ -66,10 +82,10 @@ class PlaylistOptionsDialog( context?.getString(R.string.share) -> { val shareDialog = ShareDialog(playlistId, true) // using parentFragmentManager, childFragmentManager doesn't work here - shareDialog.show(parentFragmentManager, "ShareDialog") + shareDialog.show(parentFragmentManager, ShareDialog::class.java.name) } context?.getString(R.string.deletePlaylist) -> { - val token = PreferenceHelper.getToken(requireContext()) + val token = PreferenceHelper.getToken() deletePlaylist(playlistId, token) } } diff --git a/app/src/main/java/com/github/libretube/dialogs/RequireRestartDialog.kt b/app/src/main/java/com/github/libretube/dialogs/RequireRestartDialog.kt index 1030fe05b..f22b89980 100644 --- a/app/src/main/java/com/github/libretube/dialogs/RequireRestartDialog.kt +++ b/app/src/main/java/com/github/libretube/dialogs/RequireRestartDialog.kt @@ -18,7 +18,7 @@ class RequireRestartDialog : DialogFragment() { activity?.recreate() ThemeHelper.restartMainActivity(requireContext()) } - .setNegativeButton(R.string.cancel) { _, _ -> } + .setNegativeButton(R.string.cancel, null) .create() } ?: throw IllegalStateException("Activity cannot be null") } diff --git a/app/src/main/java/com/github/libretube/dialogs/ShareDialog.kt b/app/src/main/java/com/github/libretube/dialogs/ShareDialog.kt index ed94c30ef..7509229ce 100644 --- a/app/src/main/java/com/github/libretube/dialogs/ShareDialog.kt +++ b/app/src/main/java/com/github/libretube/dialogs/ShareDialog.kt @@ -8,11 +8,13 @@ import com.github.libretube.PIPED_FRONTEND_URL import com.github.libretube.R import com.github.libretube.YOUTUBE_FRONTEND_URL import com.github.libretube.preferences.PreferenceHelper +import com.github.libretube.preferences.PreferenceKeys import com.google.android.material.dialog.MaterialAlertDialogBuilder class ShareDialog( private val id: String, - private val isPlaylist: Boolean + private val isPlaylist: Boolean, + private val position: Long = 0L ) : DialogFragment() { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { @@ -38,7 +40,14 @@ class ShareDialog( else -> instanceUrl } val path = if (!isPlaylist) "/watch?v=$id" else "/playlist?list=$id" - val url = "$host$path" + var url = "$host$path" + if (PreferenceHelper.getBoolean( + PreferenceKeys.SHARE_WITH_TIME_CODE, + true + ) + ) { + url += "?t=$position" + } val intent = Intent() intent.apply { @@ -57,13 +66,12 @@ class ShareDialog( // get the frontend url if it's a custom instance private fun getCustomInstanceFrontendUrl(): String { val instancePref = PreferenceHelper.getString( - requireContext(), - "selectInstance", + PreferenceKeys.FETCH_INSTANCE, PIPED_FRONTEND_URL ) // get the api urls of the other custom instances - val customInstances = PreferenceHelper.getCustomInstances(requireContext()) + val customInstances = PreferenceHelper.getCustomInstances() // return the custom instance frontend url if available customInstances.forEach { instance -> diff --git a/app/src/main/java/com/github/libretube/dialogs/UpdateDialog.kt b/app/src/main/java/com/github/libretube/dialogs/UpdateDialog.kt index 62beaf79c..715400bdd 100644 --- a/app/src/main/java/com/github/libretube/dialogs/UpdateDialog.kt +++ b/app/src/main/java/com/github/libretube/dialogs/UpdateDialog.kt @@ -3,43 +3,51 @@ package com.github.libretube.dialogs import android.app.Dialog import android.content.Intent import android.net.Uri +import android.os.Build import android.os.Bundle +import android.util.Log import androidx.fragment.app.DialogFragment import com.github.libretube.R +import com.github.libretube.services.UpdateService +import com.github.libretube.update.UpdateInfo +import com.github.libretube.util.PermissionHelper import com.google.android.material.dialog.MaterialAlertDialogBuilder -class UpdateAvailableDialog( - private val versionTag: String, - private val updateLink: String +class UpdateDialog( + private val updateInfo: UpdateInfo ) : DialogFragment() { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { return activity?.let { MaterialAlertDialogBuilder(requireContext()) - .setTitle(context?.getString(R.string.update_available, versionTag)) - .setMessage(context?.getString(R.string.update_available_text)) - .setNegativeButton(context?.getString(R.string.cancel)) { _, _ -> - dismiss() - } + .setTitle(context?.getString(R.string.update_available, updateInfo.name)) + .setMessage(context?.getString(R.string.update_now)) + .setNegativeButton(R.string.cancel, null) .setPositiveButton(context?.getString(R.string.okay)) { _, _ -> - val uri = Uri.parse(updateLink) - val intent = Intent(Intent.ACTION_VIEW).setData(uri) - startActivity(intent) + val downloadUrl = getDownloadUrl(updateInfo) + Log.i("downloadUrl", downloadUrl.toString()) + if (downloadUrl != null) { + PermissionHelper.requestReadWrite(requireActivity()) + val intent = Intent(context, UpdateService::class.java) + intent.putExtra("downloadUrl", downloadUrl) + context?.startService(intent) + } else { + val uri = Uri.parse(updateInfo.html_url) + val intent = Intent(Intent.ACTION_VIEW).setData(uri) + startActivity(intent) + } } .create() } ?: throw IllegalStateException("Activity cannot be null") } -} -class NoUpdateAvailableDialog() : DialogFragment() { - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - return activity?.let { - MaterialAlertDialogBuilder(requireContext()) - .setTitle(context?.getString(R.string.app_uptodate)) - .setMessage(context?.getString(R.string.no_update_available)) - .setPositiveButton(context?.getString(R.string.okay)) { _, _ -> } - .create() - } ?: throw IllegalStateException("Activity cannot be null") + private fun getDownloadUrl(updateInfo: UpdateInfo): String? { + val supportedArchitectures = Build.SUPPORTED_ABIS + supportedArchitectures.forEach { arch -> + updateInfo.assets?.forEach { asset -> + if (asset.name?.contains(arch) == true) return asset.browser_download_url + } + } + return null } } diff --git a/app/src/main/java/com/github/libretube/dialogs/VideoOptionsDialog.kt b/app/src/main/java/com/github/libretube/dialogs/VideoOptionsDialog.kt index f1c5952cc..f10d1d254 100644 --- a/app/src/main/java/com/github/libretube/dialogs/VideoOptionsDialog.kt +++ b/app/src/main/java/com/github/libretube/dialogs/VideoOptionsDialog.kt @@ -1,14 +1,17 @@ package com.github.libretube.dialogs import android.app.Dialog +import android.app.NotificationManager import android.content.Context import android.os.Bundle import android.widget.ArrayAdapter import android.widget.Toast import androidx.fragment.app.DialogFragment +import com.github.libretube.Globals +import com.github.libretube.PLAYER_NOTIFICATION_ID import com.github.libretube.R import com.github.libretube.preferences.PreferenceHelper -import com.github.libretube.util.BackgroundMode +import com.github.libretube.util.BackgroundHelper import com.google.android.material.dialog.MaterialAlertDialogBuilder /** @@ -16,24 +19,36 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder * * Needs the [videoId] to load the content from the right video. */ -class VideoOptionsDialog(private val videoId: String, context: Context) : DialogFragment() { - /** - * List that stores the different menu options. In the future could be add more options here. - */ - private val optionsList = listOf( - context.getString(R.string.playOnBackground), - context.getString(R.string.addToPlaylist), - context.getString(R.string.share) - ) +class VideoOptionsDialog( + private val videoId: String +) : DialogFragment() { + private val TAG = "VideoOptionsDialog" /** * Dialog that returns a [MaterialAlertDialogBuilder] showing a menu of options. */ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - return MaterialAlertDialogBuilder(requireContext()) - .setNegativeButton(R.string.cancel) { dialog, _ -> - dialog.dismiss() + /** + * List that stores the different menu options. In the future could be add more options here. + */ + val optionsList = mutableListOf( + context?.getString(R.string.playOnBackground), + context?.getString(R.string.addToPlaylist), + context?.getString(R.string.share) + ) + + /** + * Check whether the player is running by observing the notification + */ + val notificationManager = context?.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.activeNotifications.forEach { + if (it.id == PLAYER_NOTIFICATION_ID) { + optionsList += context?.getString(R.string.add_to_queue) } + } + + return MaterialAlertDialogBuilder(requireContext()) + .setNegativeButton(R.string.cancel, null) .setAdapter( ArrayAdapter( requireContext(), @@ -41,23 +56,23 @@ class VideoOptionsDialog(private val videoId: String, context: Context) : Dialog optionsList ) ) { _, which -> - // For now, this checks the position of the option with the position that is in the - // list. I don't like it, but we will do like this for now. when (optionsList[which]) { - // This for example will be the "Background mode" option + // Start the background mode context?.getString(R.string.playOnBackground) -> { - BackgroundMode.getInstance() - .playOnBackgroundMode(requireContext(), videoId) + BackgroundHelper.playOnBackground(requireContext(), videoId) } // Add Video to Playlist Dialog context?.getString(R.string.addToPlaylist) -> { - val token = PreferenceHelper.getToken(requireContext()) + val token = PreferenceHelper.getToken() if (token != "") { - val newFragment = AddtoPlaylistDialog() + val newFragment = AddToPlaylistDialog() val bundle = Bundle() bundle.putString("videoId", videoId) newFragment.arguments = bundle - newFragment.show(parentFragmentManager, "AddToPlaylist") + newFragment.show( + parentFragmentManager, + AddToPlaylistDialog::class.java.name + ) } else { Toast.makeText(context, R.string.login_first, Toast.LENGTH_SHORT).show() } @@ -65,14 +80,13 @@ class VideoOptionsDialog(private val videoId: String, context: Context) : Dialog context?.getString(R.string.share) -> { val shareDialog = ShareDialog(videoId, false) // using parentFragmentManager is important here - shareDialog.show(parentFragmentManager, "ShareDialog") + shareDialog.show(parentFragmentManager, ShareDialog::class.java.name) + } + context?.getString(R.string.add_to_queue) -> { + Globals.playingQueue += videoId } } } .show() } - - companion object { - const val TAG = "VideoOptionsDialog" - } } diff --git a/app/src/main/java/com/github/libretube/extensions/BaseActivity.kt b/app/src/main/java/com/github/libretube/extensions/BaseActivity.kt new file mode 100644 index 000000000..cae26114a --- /dev/null +++ b/app/src/main/java/com/github/libretube/extensions/BaseActivity.kt @@ -0,0 +1,14 @@ +package com.github.libretube.extensions + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import com.github.libretube.util.ThemeHelper + +open class BaseActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + // set the app theme (e.g. Material You) + ThemeHelper.updateTheme(this) + + super.onCreate(savedInstanceState) + } +} diff --git a/app/src/main/java/com/github/libretube/extensions/BaseFragment.kt b/app/src/main/java/com/github/libretube/extensions/BaseFragment.kt new file mode 100644 index 000000000..ded091322 --- /dev/null +++ b/app/src/main/java/com/github/libretube/extensions/BaseFragment.kt @@ -0,0 +1,10 @@ +package com.github.libretube.extensions + +import androidx.fragment.app.Fragment + +open class BaseFragment : Fragment() { + fun runOnUiThread(action: () -> Unit) { + if (!isAdded) return // Fragment not attached to an Activity + activity?.runOnUiThread(action) + } +} diff --git a/app/src/main/java/com/github/libretube/util/FormatShort.kt b/app/src/main/java/com/github/libretube/extensions/FormatShort.kt similarity index 100% rename from app/src/main/java/com/github/libretube/util/FormatShort.kt rename to app/src/main/java/com/github/libretube/extensions/FormatShort.kt diff --git a/app/src/main/java/com/github/libretube/extensions/HideKeyboard.kt b/app/src/main/java/com/github/libretube/extensions/HideKeyboard.kt new file mode 100644 index 000000000..de99f682d --- /dev/null +++ b/app/src/main/java/com/github/libretube/extensions/HideKeyboard.kt @@ -0,0 +1,11 @@ +package com.github.libretube.util + +import android.app.Activity +import android.content.Context +import android.view.View +import android.view.inputmethod.InputMethodManager + +fun Context.hideKeyboard(view: View) { + val inputMethodManager = getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager + inputMethodManager.hideSoftInputFromWindow(view.windowToken, 0) +} diff --git a/app/src/main/java/com/github/libretube/extensions/SetFormattedDuration.kt b/app/src/main/java/com/github/libretube/extensions/SetFormattedDuration.kt new file mode 100644 index 000000000..0551ee066 --- /dev/null +++ b/app/src/main/java/com/github/libretube/extensions/SetFormattedDuration.kt @@ -0,0 +1,14 @@ +package com.github.libretube.extensions + +import android.text.format.DateUtils +import android.widget.TextView +import com.github.libretube.R + +fun TextView?.setFormattedDuration(duration: Long) { + val text = if (duration < 0L) { + this!!.setBackgroundColor(R.attr.colorPrimaryDark) + this.context.getString(R.string.live) + } else if (duration == 0L) this!!.context.getString(R.string.yt_shorts) + else DateUtils.formatElapsedTime(duration) + this!!.text = text +} diff --git a/app/src/main/java/com/github/libretube/extensions/SetWatchProgressLength.kt b/app/src/main/java/com/github/libretube/extensions/SetWatchProgressLength.kt new file mode 100644 index 000000000..22780a71e --- /dev/null +++ b/app/src/main/java/com/github/libretube/extensions/SetWatchProgressLength.kt @@ -0,0 +1,39 @@ +package com.github.libretube.util + +import android.view.View +import android.view.ViewTreeObserver +import android.widget.LinearLayout +import com.github.libretube.preferences.PreferenceHelper + +/** + * shows the already watched time under the video + */ +fun View?.setWatchProgressLength(videoId: String, duration: Long) { + val view = this!! + val positions = PreferenceHelper.getWatchPositions() + var newWidth: Long? = null + view.getViewTreeObserver() + .addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener { + override fun onGlobalLayout() { + this@setWatchProgressLength.getViewTreeObserver().removeOnGlobalLayoutListener(this) + positions.forEach { + if (it.videoId == videoId) { + val fullWidth = (parent as LinearLayout).width + if (duration != 0L) newWidth = + (fullWidth * (it.position / (duration))) / 1000 + return@forEach + } + } + if (newWidth != null) { + val lp = view.layoutParams + lp.apply { + width = newWidth!!.toInt() + } + view.layoutParams = lp + view.visibility = View.VISIBLE + } else { + view.visibility = View.GONE + } + } + }) +} diff --git a/app/src/main/java/com/github/libretube/extensions/ToID.kt b/app/src/main/java/com/github/libretube/extensions/ToID.kt new file mode 100644 index 000000000..43ff8420d --- /dev/null +++ b/app/src/main/java/com/github/libretube/extensions/ToID.kt @@ -0,0 +1,12 @@ +package com.github.libretube.util + +/** + * format a Piped route to an ID + */ +fun Any?.toID(): String { + return this!! + .toString() + .replace("/watch?v=", "") // videos + .replace("/channel/", "") // channels + .replace("/playlist?list=", "") // playlists +} diff --git a/app/src/main/java/com/github/libretube/fragments/ChannelFragment.kt b/app/src/main/java/com/github/libretube/fragments/ChannelFragment.kt index ffcf56b7c..5399e75dc 100644 --- a/app/src/main/java/com/github/libretube/fragments/ChannelFragment.kt +++ b/app/src/main/java/com/github/libretube/fragments/ChannelFragment.kt @@ -1,39 +1,43 @@ package com.github.libretube.fragments -import android.annotation.SuppressLint import android.os.Bundle import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import com.github.libretube.R import com.github.libretube.adapters.ChannelAdapter import com.github.libretube.databinding.FragmentChannelBinding -import com.github.libretube.obj.Subscribe -import com.github.libretube.preferences.PreferenceHelper +import com.github.libretube.extensions.BaseFragment +import com.github.libretube.util.ConnectionHelper import com.github.libretube.util.RetrofitInstance +import com.github.libretube.util.SubscriptionHelper import com.github.libretube.util.formatShort -import com.squareup.picasso.Picasso +import com.github.libretube.util.toID import retrofit2.HttpException import java.io.IOException -class ChannelFragment : Fragment() { +class ChannelFragment : BaseFragment() { private val TAG = "ChannelFragment" private lateinit var binding: FragmentChannelBinding private var channelId: String? = null + private var channelName: String? = null + var nextPage: String? = null private var channelAdapter: ChannelAdapter? = null private var isLoading = true - private var isSubscribed: Boolean = false + private var isSubscribed: Boolean? = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) arguments?.let { - channelId = it.getString("channel_id") + channelId = it.getString("channel_id").toID() + channelName = it.getString("channel_name") + ?.replace("/c/", "") + ?.replace("/user/", "") } } @@ -49,16 +53,13 @@ class ChannelFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - channelId = channelId!!.replace("/channel/", "") binding.channelName.text = channelId binding.channelRecView.layoutManager = LinearLayoutManager(context) val refreshChannel = { binding.channelRefresh.isRefreshing = true fetchChannel() - if (PreferenceHelper.getToken(requireContext()) != "") { - isSubscribed() - } + isSubscribed() } refreshChannel() binding.channelRefresh.setOnRefreshListener { @@ -74,92 +75,43 @@ class ChannelFragment : Fragment() { if (nextPage != null && !isLoading) { isLoading = true binding.channelRefresh.isRefreshing = true - fetchNextPage() + fetchChannelNextPage() } } } } private fun isSubscribed() { - @SuppressLint("ResourceAsColor") - fun run() { - lifecycleScope.launchWhenCreated { - val response = try { - val token = PreferenceHelper.getToken(requireContext()) - RetrofitInstance.authApi.isSubscribed( - channelId!!, - token - ) - } catch (e: Exception) { - Log.e(TAG, e.toString()) - return@launchWhenCreated + lifecycleScope.launchWhenCreated { + isSubscribed = SubscriptionHelper.isSubscribed(channelId!!) + if (isSubscribed == null) return@launchWhenCreated + + runOnUiThread { + if (isSubscribed == true) { + binding.channelSubscribe.text = getString(R.string.unsubscribe) } - runOnUiThread { - if (response.subscribed == true) { + binding.channelSubscribe.setOnClickListener { + binding.channelSubscribe.text = if (isSubscribed == true) { + SubscriptionHelper.unsubscribe(channelId!!) + isSubscribed = false + getString(R.string.subscribe) + } else { + SubscriptionHelper.subscribe(channelId!!) isSubscribed = true - binding.channelSubscribe.text = getString(R.string.unsubscribe) - } - if (response.subscribed != null) { - binding.channelSubscribe.apply { - setOnClickListener { - text = if (isSubscribed) { - unsubscribe() - getString(R.string.subscribe) - } else { - subscribe() - getString(R.string.unsubscribe) - } - } - } + getString(R.string.unsubscribe) } } } } - run() - } - - private fun subscribe() { - fun run() { - lifecycleScope.launchWhenCreated { - try { - val token = PreferenceHelper.getToken(requireContext()) - RetrofitInstance.authApi.subscribe( - token, - Subscribe(channelId) - ) - } catch (e: Exception) { - Log.e(TAG, e.toString()) - } - isSubscribed = true - } - } - run() - } - - private fun unsubscribe() { - fun run() { - lifecycleScope.launchWhenCreated { - try { - val token = PreferenceHelper.getToken(requireContext()) - RetrofitInstance.authApi.unsubscribe( - token, - Subscribe(channelId) - ) - } catch (e: Exception) { - Log.e(TAG, e.toString()) - } - isSubscribed = false - } - } - run() } private fun fetchChannel() { fun run() { lifecycleScope.launchWhenCreated { val response = try { - RetrofitInstance.api.getChannel(channelId!!) + if (channelId != null) RetrofitInstance.api.getChannel(channelId!!) + else RetrofitInstance.api.getChannelByName(channelName!!) } catch (e: IOException) { binding.channelRefresh.isRefreshing = false println(e) @@ -194,8 +146,10 @@ class ChannelFragment : Fragment() { binding.channelDescription.text = response.description?.trim() } - Picasso.get().load(response.bannerUrl).into(binding.channelBanner) - Picasso.get().load(response.avatarUrl).into(binding.channelImage) + ConnectionHelper.loadImage(response.bannerUrl, binding.channelBanner) + ConnectionHelper.loadImage(response.avatarUrl, binding.channelImage) + + // recyclerview of the videos by the channel channelAdapter = ChannelAdapter( response.relatedStreams!!.toMutableList(), childFragmentManager @@ -207,7 +161,7 @@ class ChannelFragment : Fragment() { run() } - private fun fetchNextPage() { + private fun fetchChannelNextPage() { fun run() { lifecycleScope.launchWhenCreated { val response = try { @@ -230,10 +184,4 @@ class ChannelFragment : Fragment() { } run() } - - private fun Fragment?.runOnUiThread(action: () -> Unit) { - this ?: return - if (!isAdded) return // Fragment not attached to an Activity - activity?.runOnUiThread(action) - } } diff --git a/app/src/main/java/com/github/libretube/fragments/HomeFragment.kt b/app/src/main/java/com/github/libretube/fragments/HomeFragment.kt index 832573fab..2a1312e55 100644 --- a/app/src/main/java/com/github/libretube/fragments/HomeFragment.kt +++ b/app/src/main/java/com/github/libretube/fragments/HomeFragment.kt @@ -6,19 +6,20 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.Toast -import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.GridLayoutManager import com.github.libretube.R import com.github.libretube.adapters.TrendingAdapter import com.github.libretube.databinding.FragmentHomeBinding +import com.github.libretube.extensions.BaseFragment import com.github.libretube.preferences.PreferenceHelper +import com.github.libretube.preferences.PreferenceKeys import com.github.libretube.util.LocaleHelper import com.github.libretube.util.RetrofitInstance import retrofit2.HttpException import java.io.IOException -class HomeFragment : Fragment() { +class HomeFragment : BaseFragment() { private val TAG = "HomeFragment" private lateinit var binding: FragmentHomeBinding private lateinit var region: String @@ -41,12 +42,11 @@ class HomeFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val grid = PreferenceHelper.getString( - requireContext(), - "grid", + PreferenceKeys.GRID_COLUMNS, resources.getInteger(R.integer.grid_items).toString() )!! - val regionPref = PreferenceHelper.getString(requireContext(), "region", "sys")!! + val regionPref = PreferenceHelper.getString(PreferenceKeys.REGION, "sys")!! // get the system default country if auto region selected region = if (regionPref == "sys") { @@ -88,10 +88,4 @@ class HomeFragment : Fragment() { } run() } - - private fun Fragment?.runOnUiThread(action: () -> Unit) { - this ?: return - if (!isAdded) return // Fragment not attached to an Activity - activity?.runOnUiThread(action) - } } diff --git a/app/src/main/java/com/github/libretube/fragments/LibraryFragment.kt b/app/src/main/java/com/github/libretube/fragments/LibraryFragment.kt index b5ceea33b..1d306390c 100644 --- a/app/src/main/java/com/github/libretube/fragments/LibraryFragment.kt +++ b/app/src/main/java/com/github/libretube/fragments/LibraryFragment.kt @@ -6,21 +6,23 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.Toast -import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView import com.github.libretube.Globals import com.github.libretube.R import com.github.libretube.adapters.PlaylistsAdapter import com.github.libretube.databinding.FragmentLibraryBinding import com.github.libretube.dialogs.CreatePlaylistDialog +import com.github.libretube.extensions.BaseFragment import com.github.libretube.preferences.PreferenceHelper +import com.github.libretube.preferences.PreferenceKeys import com.github.libretube.util.RetrofitInstance import retrofit2.HttpException import java.io.IOException -class LibraryFragment : Fragment() { +class LibraryFragment : BaseFragment() { private val TAG = "LibraryFragment" lateinit var token: String @@ -43,12 +45,12 @@ class LibraryFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - binding.playlistRecView.layoutManager = LinearLayoutManager(view.context) - token = PreferenceHelper.getToken(requireContext()) + binding.playlistRecView.layoutManager = LinearLayoutManager(requireContext()) + token = PreferenceHelper.getToken() // hide watch history button of history disabled val watchHistoryEnabled = - PreferenceHelper.getBoolean(requireContext(), "watch_history_toggle", true) + PreferenceHelper.getBoolean(PreferenceKeys.WATCH_HISTORY_TOGGLE, true) if (!watchHistoryEnabled) { binding.showWatchHistory.visibility = View.GONE } else { @@ -58,8 +60,7 @@ class LibraryFragment : Fragment() { } if (token != "") { - binding.boogh.visibility = View.GONE - binding.textLike.visibility = View.GONE + binding.loginOrRegister.visibility = View.GONE fetchPlaylists() binding.playlistRefresh.isEnabled = true binding.playlistRefresh.setOnRefreshListener { @@ -67,7 +68,7 @@ class LibraryFragment : Fragment() { } binding.createPlaylist.setOnClickListener { val newFragment = CreatePlaylistDialog() - newFragment.show(childFragmentManager, "Create Playlist") + newFragment.show(childFragmentManager, CreatePlaylistDialog::class.java.name) } } else { binding.playlistRefresh.isEnabled = false @@ -78,7 +79,7 @@ class LibraryFragment : Fragment() { override fun onResume() { // optimize CreatePlaylistFab bottom margin if miniPlayer active val layoutParams = binding.createPlaylist.layoutParams as ViewGroup.MarginLayoutParams - layoutParams.bottomMargin = if (Globals.isMiniPlayerVisible) 180 else 64 + layoutParams.bottomMargin = if (Globals.MINI_PLAYER_VISIBLE) 180 else 64 binding.createPlaylist.layoutParams = layoutParams super.onResume() } @@ -102,35 +103,36 @@ class LibraryFragment : Fragment() { binding.playlistRefresh.isRefreshing = false } if (response.isNotEmpty()) { - runOnUiThread { - binding.boogh.visibility = View.GONE - binding.textLike.visibility = View.GONE - } + binding.loginOrRegister.visibility = View.GONE + val playlistsAdapter = PlaylistsAdapter( response.toMutableList(), + childFragmentManager, requireActivity() ) + + // listen for playlists to become deleted + playlistsAdapter.registerAdapterDataObserver(object : + RecyclerView.AdapterDataObserver() { + override fun onChanged() { + Log.e(TAG, playlistsAdapter.itemCount.toString()) + if (playlistsAdapter.itemCount == 0) { + binding.loginOrRegister.visibility = View.VISIBLE + } + super.onChanged() + } + }) + binding.playlistRecView.adapter = playlistsAdapter } else { runOnUiThread { - binding.boogh.apply { - visibility = View.VISIBLE - setImageResource(R.drawable.ic_list) - } - binding.textLike.apply { - visibility = View.VISIBLE - text = getString(R.string.emptyList) - } + binding.loginOrRegister.visibility = View.VISIBLE + binding.boogh.setImageResource(R.drawable.ic_list) + binding.textLike.text = getString(R.string.emptyList) } } } } run() } - - private fun Fragment?.runOnUiThread(action: () -> Unit) { - this ?: return - if (!isAdded) return // Fragment not attached to an Activity - activity?.runOnUiThread(action) - } } diff --git a/app/src/main/java/com/github/libretube/fragments/PlayerFragment.kt b/app/src/main/java/com/github/libretube/fragments/PlayerFragment.kt index b05624a71..8d3f43cdd 100644 --- a/app/src/main/java/com/github/libretube/fragments/PlayerFragment.kt +++ b/app/src/main/java/com/github/libretube/fragments/PlayerFragment.kt @@ -1,13 +1,11 @@ package com.github.libretube.fragments -import android.annotation.SuppressLint -import android.app.NotificationManager +import android.app.ActivityManager import android.app.PictureInPictureParams import android.content.Context import android.content.Intent import android.content.pm.ActivityInfo import android.content.res.Configuration -import android.graphics.Color import android.graphics.Rect import android.net.Uri import android.os.Build @@ -16,11 +14,11 @@ import android.os.Bundle import android.os.Handler import android.os.Looper import android.os.PowerManager -import android.support.v4.media.session.MediaSessionCompat import android.text.Html -import android.text.TextUtils +import android.text.format.DateUtils import android.util.Log import android.view.LayoutInflater +import android.view.MotionEvent import android.view.View import android.view.ViewGroup import android.widget.Toast @@ -29,39 +27,44 @@ import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.net.toUri import androidx.core.os.bundleOf import androidx.core.view.isVisible -import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager +import com.fasterxml.jackson.databind.ObjectMapper import com.github.libretube.Globals import com.github.libretube.R import com.github.libretube.activities.MainActivity -import com.github.libretube.activities.hideKeyboard import com.github.libretube.adapters.ChaptersAdapter import com.github.libretube.adapters.CommentsAdapter import com.github.libretube.adapters.TrendingAdapter +import com.github.libretube.databinding.DoubleTapOverlayBinding import com.github.libretube.databinding.ExoStyledPlayerControlViewBinding import com.github.libretube.databinding.FragmentPlayerBinding -import com.github.libretube.dialogs.AddtoPlaylistDialog +import com.github.libretube.dialogs.AddToPlaylistDialog import com.github.libretube.dialogs.DownloadDialog import com.github.libretube.dialogs.ShareDialog +import com.github.libretube.extensions.BaseFragment +import com.github.libretube.interfaces.DoubleTapInterface +import com.github.libretube.interfaces.PlayerOptionsInterface import com.github.libretube.obj.ChapterSegment -import com.github.libretube.obj.PipedStream -import com.github.libretube.obj.Playlist import com.github.libretube.obj.Segment import com.github.libretube.obj.Segments -import com.github.libretube.obj.SponsorBlockPrefs -import com.github.libretube.obj.StreamItem import com.github.libretube.obj.Streams -import com.github.libretube.obj.Subscribe import com.github.libretube.preferences.PreferenceHelper -import com.github.libretube.services.IS_DOWNLOAD_RUNNING -import com.github.libretube.util.BackgroundMode +import com.github.libretube.preferences.PreferenceKeys +import com.github.libretube.services.BackgroundMode +import com.github.libretube.util.AutoPlayHelper +import com.github.libretube.util.BackgroundHelper +import com.github.libretube.util.ConnectionHelper import com.github.libretube.util.CronetHelper -import com.github.libretube.util.DescriptionAdapter +import com.github.libretube.util.NowPlayingNotification +import com.github.libretube.util.PlayerHelper import com.github.libretube.util.RetrofitInstance +import com.github.libretube.util.SubscriptionHelper import com.github.libretube.util.formatShort -import com.github.libretube.views.DoubleClickListener +import com.github.libretube.util.hideKeyboard +import com.github.libretube.util.toID +import com.github.libretube.views.BottomSheetFragment import com.google.android.exoplayer2.C import com.google.android.exoplayer2.DefaultLoadControl import com.google.android.exoplayer2.ExoPlayer @@ -71,23 +74,20 @@ import com.google.android.exoplayer2.MediaItem.fromUri import com.google.android.exoplayer2.Player import com.google.android.exoplayer2.audio.AudioAttributes import com.google.android.exoplayer2.ext.cronet.CronetDataSource -import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector import com.google.android.exoplayer2.source.DefaultMediaSourceFactory import com.google.android.exoplayer2.source.MediaSource import com.google.android.exoplayer2.source.MergingMediaSource import com.google.android.exoplayer2.source.ProgressiveMediaSource +import com.google.android.exoplayer2.trackselection.DefaultTrackSelector import com.google.android.exoplayer2.ui.AspectRatioFrameLayout -import com.google.android.exoplayer2.ui.PlayerNotificationManager +import com.google.android.exoplayer2.ui.CaptionStyleCompat import com.google.android.exoplayer2.ui.StyledPlayerView -import com.google.android.exoplayer2.ui.TimeBar import com.google.android.exoplayer2.upstream.DataSource import com.google.android.exoplayer2.upstream.DefaultDataSource import com.google.android.exoplayer2.upstream.DefaultHttpDataSource import com.google.android.exoplayer2.util.RepeatModeUtil import com.google.android.exoplayer2.video.VideoSize -import com.google.android.material.button.MaterialButton import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.squareup.picasso.Picasso import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -97,57 +97,89 @@ import java.io.IOException import java.util.concurrent.Executors import kotlin.math.abs -class PlayerFragment : Fragment() { +class PlayerFragment : BaseFragment() { private val TAG = "PlayerFragment" private lateinit var binding: FragmentPlayerBinding private lateinit var playerBinding: ExoStyledPlayerControlViewBinding + private lateinit var doubleTapOverlayBinding: DoubleTapOverlayBinding + /** + * video information + */ private var videoId: String? = null private var playlistId: String? = null + private var isSubscribed: Boolean? = false + private var isLive = false + private lateinit var streams: Streams + + /** + * for the transition + */ private var sId: Int = 0 private var eId: Int = 0 - private var paused = false - private var whichQuality = 0 private var transitioning = false - private var autoplay = false - private var isZoomed: Boolean = false - - private var isSubscribed: Boolean = false + /** + * for the comments + */ private var commentsAdapter: CommentsAdapter? = null private var commentsLoaded: Boolean? = false private var nextPage: String? = null private var isLoading = true - private lateinit var exoPlayerView: StyledPlayerView + + /** + * for the player + */ private lateinit var exoPlayer: ExoPlayer + private lateinit var trackSelector: DefaultTrackSelector private lateinit var segmentData: Segments - private var relatedStreamsEnabled = true - - private var relatedStreams: List? = arrayListOf() - private var nextStreamId: String? = null - private var playlistStreamIds: MutableList = arrayListOf() - private var playlistNextPage: String? = null - - private var isPlayerLocked: Boolean = false - - private lateinit var mediaSession: MediaSessionCompat - private lateinit var mediaSessionConnector: MediaSessionConnector - private lateinit var playerNotification: PlayerNotificationManager - - private lateinit var title: String - private lateinit var uploader: String - private lateinit var thumbnailUrl: String private lateinit var chapters: List - private val sponsorBlockPrefs = SponsorBlockPrefs() - private lateinit var subtitle: MutableList + /** + * for the player view + */ + private lateinit var exoPlayerView: StyledPlayerView + private var isPlayerLocked: Boolean = false + private var subtitle = mutableListOf() + + /** + * user preferences + */ + private var token = "" + private var relatedStreamsEnabled = true + private var autoplayEnabled = false private var autoRotationEnabled = true + private var playbackSpeed = "1F" + private var pausePlayerOnScreenOffEnabled = false + private var fullscreenOrientationPref = "ratio" + private var watchHistoryEnabled = true + private var watchPositionsEnabled = true + private var useSystemCaptionStyle = true + private var seekIncrement = 5L + private var videoFormatPreference = "webm" + private var defRes = "" + private var bufferingGoal = 50000 + private var defaultSubtitleCode = "" + private var sponsorBlockEnabled = true + private var sponsorBlockNotifications = true + private var skipButtonsEnabled = false + + /** + * for autoplay + */ + private var nextStreamId: String? = null + private lateinit var autoPlayHelper: AutoPlayHelper + + /** + * for the player notification + */ + private lateinit var nowPlayingNotification: NowPlayingNotification override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) arguments?.let { - videoId = it.getString("videoId") + videoId = it.getString("videoId").toID() playlistId = it.getString("playlistId") } } @@ -158,57 +190,149 @@ class PlayerFragment : Fragment() { savedInstanceState: Bundle? ): View { binding = FragmentPlayerBinding.inflate(layoutInflater, container, false) + exoPlayerView = binding.player playerBinding = binding.player.binding + doubleTapOverlayBinding = binding.doubleTapOverlay.binding + // Inflate the layout for this fragment return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - hideKeyboard() + context?.hideKeyboard(view) + + // clear the playing queue + Globals.playingQueue.clear() + + setUserPrefs() - // save whether auto rotation is enabled - autoRotationEnabled = PreferenceHelper.getBoolean( - requireContext(), - "auto_fullscreen", - false - ) val mainActivity = activity as MainActivity if (autoRotationEnabled) { // enable auto rotation - mainActivity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER + mainActivity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR onConfigurationChanged(resources.configuration) } else { // go to portrait mode mainActivity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT } + createExoPlayer() + initializeTransitionLayout() + initializeOnClickActions() + playVideo() + + showBottomBar() + } + + /** + * somehow the bottom bar is invisible on low screen resolutions, this fixes it + */ + private fun showBottomBar() { + if (this::playerBinding.isInitialized && !isPlayerLocked) { + playerBinding.exoBottomBar.visibility = View.VISIBLE + } + Handler(Looper.getMainLooper()).postDelayed(this::showBottomBar, 100) + } + + private fun setUserPrefs() { + token = PreferenceHelper.getToken() + + // save whether auto rotation is enabled + autoRotationEnabled = PreferenceHelper.getBoolean( + PreferenceKeys.AUTO_FULLSCREEN, + false + ) + // save whether related streams and autoplay are enabled - autoplay = PreferenceHelper.getBoolean( - requireContext(), - "autoplay", + autoplayEnabled = PreferenceHelper.getBoolean( + PreferenceKeys.AUTO_PLAY, false ) relatedStreamsEnabled = PreferenceHelper.getBoolean( - requireContext(), - "related_streams_toggle", + PreferenceKeys.RELATED_STREAMS, true ) - setSponsorBlockPrefs() - createExoPlayer(view) - initializeTransitionLayout(view) - playVideo(view) + playbackSpeed = PreferenceHelper.getString( + PreferenceKeys.PLAYBACK_SPEED, + "1" + ).replace("F", "") // due to old way to handle it (with float) + + fullscreenOrientationPref = PreferenceHelper.getString( + PreferenceKeys.FULLSCREEN_ORIENTATION, + "ratio" + ) + + pausePlayerOnScreenOffEnabled = PreferenceHelper.getBoolean( + PreferenceKeys.PAUSE_ON_SCREEN_OFF, + false + ) + + watchPositionsEnabled = PreferenceHelper.getBoolean( + PreferenceKeys.WATCH_POSITION_TOGGLE, + true + ) + + watchHistoryEnabled = PreferenceHelper.getBoolean( + PreferenceKeys.WATCH_HISTORY_TOGGLE, + true + ) + + useSystemCaptionStyle = PreferenceHelper.getBoolean( + PreferenceKeys.SYSTEM_CAPTION_STYLE, + true + ) + + seekIncrement = PreferenceHelper.getString( + PreferenceKeys.SEEK_INCREMENT, + "5" + ).toLong() * 1000 + + videoFormatPreference = PreferenceHelper.getString( + PreferenceKeys.PLAYER_VIDEO_FORMAT, + "webm" + ) + + defRes = PreferenceHelper.getString( + PreferenceKeys.DEFAULT_RESOLUTION, + "" + ) + + bufferingGoal = PreferenceHelper.getString( + PreferenceKeys.BUFFERING_GOAL, + "50" + ).toInt() * 1000 + + sponsorBlockEnabled = PreferenceHelper.getBoolean( + "sb_enabled_key", + true + ) + + sponsorBlockNotifications = PreferenceHelper.getBoolean( + "sb_notifications_key", + true + ) + + defaultSubtitleCode = PreferenceHelper.getString( + PreferenceKeys.DEFAULT_SUBTITLE, + "" + ) + + if (defaultSubtitleCode.contains("-")) { + defaultSubtitleCode = defaultSubtitleCode.split("-")[0] + } + + skipButtonsEnabled = PreferenceHelper.getBoolean( + PreferenceKeys.SKIP_BUTTONS, + false + ) } - private fun initializeTransitionLayout(view: View) { - videoId = videoId!!.replace("/watch?v=", "") - + private fun initializeTransitionLayout() { val mainActivity = activity as MainActivity mainActivity.binding.container.visibility = View.VISIBLE - exoPlayerView = binding.player - binding.playerMotionLayout.addTransitionListener(object : MotionLayout.TransitionListener { override fun onTransitionStarted( motionLayout: MotionLayout?, @@ -238,11 +362,11 @@ class PlayerFragment : Fragment() { val mainMotionLayout = mainActivity.binding.mainMotionLayout if (currentId == eId) { - Globals.isMiniPlayerVisible = true + Globals.MINI_PLAYER_VISIBLE = true exoPlayerView.useController = false mainMotionLayout.progress = 1F } else if (currentId == sId) { - Globals.isMiniPlayerVisible = false + Globals.MINI_PLAYER_VISIBLE = false exoPlayerView.useController = true mainMotionLayout.progress = 0F } @@ -260,8 +384,176 @@ class PlayerFragment : Fragment() { binding.playerMotionLayout.progress = 1.toFloat() binding.playerMotionLayout.transitionToStart() + // quitting miniPlayer on single click + binding.titleTextView.setOnTouchListener { view, motionEvent -> + view.onTouchEvent(motionEvent) + if (motionEvent.action == MotionEvent.ACTION_UP) view.performClick() + binding.root.onTouchEvent(motionEvent) + } + binding.titleTextView.setOnClickListener { + binding.playerMotionLayout.setTransitionDuration(300) + binding.playerMotionLayout.transitionToStart() + } + } + + private val playerOptionsInterface = object : PlayerOptionsInterface { + override fun onAutoplayClicked() { + // autoplay options dialog + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.player_autoplay) + .setItems( + arrayOf( + context?.getString(R.string.enabled), + context?.getString(R.string.disabled) + ) + ) { _, index -> + when (index) { + 0 -> autoplayEnabled = true + 1 -> autoplayEnabled = false + } + } + .show() + } + + override fun onCaptionClicked() { + if (!this@PlayerFragment::streams.isInitialized || + streams.subtitles == null || + streams.subtitles!!.isEmpty() + ) { + Toast.makeText(context, R.string.no_subtitles_available, Toast.LENGTH_SHORT).show() + return + } + + val subtitlesNamesList = mutableListOf(context?.getString(R.string.none)!!) + val subtitleCodesList = mutableListOf("") + streams.subtitles!!.forEach { + subtitlesNamesList += it.name!! + subtitleCodesList += it.code!! + } + + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.captions) + .setItems(subtitlesNamesList.toTypedArray()) { _, index -> + val newParams = if (index != 0) { + // caption selected + + // get the caption name and language + val captionLanguage = subtitlesNamesList[index] + val captionLanguageCode = subtitleCodesList[index] + + // select the new caption preference + trackSelector.buildUponParameters() + .setPreferredTextLanguages( + captionLanguage, + captionLanguageCode + ) + .setPreferredTextRoleFlags(C.ROLE_FLAG_CAPTION) + } else { + // none selected + // disable captions + trackSelector.buildUponParameters() + .setPreferredTextLanguage("") + } + + // set the new caption language + trackSelector.setParameters(newParams) + } + .show() + } + + override fun onQualityClicked() { + // get the available resolutions + val (videosNameArray, videosUrlArray) = getAvailableResolutions() + + // Dialog for quality selection + val lastPosition = exoPlayer.currentPosition + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.choose_quality_dialog) + .setItems( + videosNameArray + ) { _, which -> + if ( + videosNameArray[which] == getString(R.string.hls) || + videosNameArray[which] == "LBRY HLS" + ) { + // no need to merge sources if using hls + val mediaItem: MediaItem = MediaItem.Builder() + .setUri(videosUrlArray[which]) + .setSubtitleConfigurations(subtitle) + .build() + exoPlayer.setMediaItem(mediaItem) + } else { + val videoUri = videosUrlArray[which] + val audioUrl = PlayerHelper.getAudioSource(streams.audioStreams!!) + setMediaSource(videoUri, audioUrl) + } + exoPlayer.seekTo(lastPosition) + } + .show() + } + + override fun onPlaybackSpeedClicked() { + val playbackSpeeds = context?.resources?.getStringArray(R.array.playbackSpeed)!! + val playbackSpeedValues = + context?.resources?.getStringArray(R.array.playbackSpeedValues)!! + + // change playback speed dialog + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.change_playback_speed) + .setItems(playbackSpeeds) { _, index -> + // set the new playback speed + val newPlaybackSpeed = playbackSpeedValues[index].toFloat() + exoPlayer.setPlaybackSpeed(newPlaybackSpeed) + } + .show() + } + + override fun onAspectRatioClicked() { + // switching between original aspect ratio (black bars) and zoomed to fill device screen + val aspectRatioModeNames = arrayOf( + context?.getString(R.string.resize_mode_fit), + context?.getString(R.string.resize_mode_zoom), + context?.getString(R.string.resize_mode_fill) + ) + + val aspectRatioModes = arrayOf( + AspectRatioFrameLayout.RESIZE_MODE_FIT, + AspectRatioFrameLayout.RESIZE_MODE_ZOOM, + AspectRatioFrameLayout.RESIZE_MODE_FILL + ) + + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.aspect_ratio) + .setItems(aspectRatioModeNames) { _, index -> + exoPlayerView.resizeMode = aspectRatioModes[index] + } + .show() + } + + override fun onRepeatModeClicked() { + val repeatModeNames = arrayOf( + context?.getString(R.string.repeat_mode_none), + context?.getString(R.string.repeat_mode_current) + ) + + val repeatModes = arrayOf( + RepeatModeUtil.REPEAT_TOGGLE_MODE_ALL, + RepeatModeUtil.REPEAT_TOGGLE_MODE_NONE + ) + // repeat mode options dialog + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.repeat_mode) + .setItems(repeatModeNames) { _, index -> + exoPlayer.repeatMode = repeatModes[index] + } + .show() + } + } + + // actions that don't depend on video information + private fun initializeOnClickActions() { binding.closeImageView.setOnClickListener { - Globals.isMiniPlayerVisible = false + Globals.MINI_PLAYER_VISIBLE = false binding.playerMotionLayout.transitionToEnd() val mainActivity = activity as MainActivity mainActivity.supportFragmentManager.beginTransaction() @@ -269,22 +561,30 @@ class PlayerFragment : Fragment() { .commit() } playerBinding.closeImageButton.setOnClickListener { - Globals.isMiniPlayerVisible = false + Globals.MINI_PLAYER_VISIBLE = false binding.playerMotionLayout.transitionToEnd() val mainActivity = activity as MainActivity mainActivity.supportFragmentManager.beginTransaction() .remove(this) .commit() } + // show the advanced player options + playerBinding.toggleOptions.setOnClickListener { + val bottomSheetFragment = BottomSheetFragment().apply { + setOnClickListeners(playerOptionsInterface) + } + bottomSheetFragment.show(childFragmentManager, null) + } + binding.playImageView.setOnClickListener { - paused = if (paused) { + if (!exoPlayer.isPlaying) { + // start or go on playing binding.playImageView.setImageResource(R.drawable.ic_pause) exoPlayer.play() - false } else { + // pause the video binding.playImageView.setImageResource(R.drawable.ic_play) exoPlayer.pause() - true } } @@ -303,7 +603,7 @@ class PlayerFragment : Fragment() { playerBinding.fullscreen.setOnClickListener { // hide player controller exoPlayerView.hideController() - if (!Globals.isFullScreen) { + if (!Globals.IS_FULL_SCREEN) { // go to fullscreen mode setFullscreen() } else { @@ -312,17 +612,6 @@ class PlayerFragment : Fragment() { } } - // switching between original aspect ratio (black bars) and zoomed to fill device screen - playerBinding.aspectRatioButton.setOnClickListener { - if (isZoomed) { - exoPlayerView.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT - isZoomed = false - } else { - exoPlayerView.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM - isZoomed = true - } - } - // lock and unlock the player playerBinding.lockPlayer.setOnClickListener { // change the locked/unlocked icon @@ -339,6 +628,27 @@ class PlayerFragment : Fragment() { isPlayerLocked = !isPlayerLocked } + // set default playback speed + exoPlayer.setPlaybackSpeed(playbackSpeed.toFloat()) + + // share button + binding.relPlayerShare.setOnClickListener { + val shareDialog = ShareDialog(videoId!!, false, exoPlayer.currentPosition) + shareDialog.show(childFragmentManager, ShareDialog::class.java.name) + } + + binding.relPlayerBackground.setOnClickListener { + // pause the current player + exoPlayer.pause() + + // start the background mode + BackgroundHelper.playOnBackground( + requireContext(), + videoId!!, + exoPlayer.currentPosition + ) + } + binding.playerScrollView.viewTreeObserver .addOnScrollChangedListener { if (binding.playerScrollView.getChildAt(0).bottom @@ -349,11 +659,11 @@ class PlayerFragment : Fragment() { } } - binding.commentsRecView.layoutManager = LinearLayoutManager(view.context) + binding.commentsRecView.layoutManager = LinearLayoutManager(view?.context) binding.commentsRecView.setItemViewCacheSize(20) binding.relatedRecView.layoutManager = - GridLayoutManager(view.context, resources.getInteger(R.integer.grid_items)) + GridLayoutManager(view?.context, resources.getInteger(R.integer.grid_items)) } private fun setFullscreen() { @@ -368,11 +678,6 @@ class PlayerFragment : Fragment() { playerBinding.exoTitle.visibility = View.VISIBLE val mainActivity = activity as MainActivity - val fullscreenOrientationPref = PreferenceHelper - .getString(requireContext(), "fullscreen_orientation", "ratio") - - scaleControls(1.3F) - if (!autoRotationEnabled) { // different orientations of the video are only available when auto rotation is disabled val orientation = when (fullscreenOrientationPref) { @@ -381,17 +686,17 @@ class PlayerFragment : Fragment() { // probably a youtube shorts video if (videoSize.height > videoSize.width) ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT // a video with normal aspect ratio - else ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE + else ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE } - "auto" -> ActivityInfo.SCREEN_ORIENTATION_USER - "landscape" -> ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE + "auto" -> ActivityInfo.SCREEN_ORIENTATION_SENSOR + "landscape" -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE "portrait" -> ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT - else -> ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE + else -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE } mainActivity.requestedOrientation = orientation } - Globals.isFullScreen = true + Globals.IS_FULL_SCREEN = true } private fun unsetFullscreen() { @@ -406,26 +711,34 @@ class PlayerFragment : Fragment() { playerBinding.fullscreen.setImageResource(R.drawable.ic_fullscreen) playerBinding.exoTitle.visibility = View.INVISIBLE - scaleControls(1F) - if (!autoRotationEnabled) { // switch back to portrait mode if auto rotation disabled val mainActivity = activity as MainActivity mainActivity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT } - Globals.isFullScreen = false - } - - private fun scaleControls(scaleFactor: Float) { - playerBinding.exoPlayPause.scaleX = scaleFactor - playerBinding.exoPlayPause.scaleY = scaleFactor + Globals.IS_FULL_SCREEN = false } private fun toggleDescription() { - binding.playerDescriptionArrow.animate().rotationBy(180F).setDuration(250).start() - binding.descLinLayout.visibility = - if (binding.descLinLayout.isVisible) View.GONE else View.VISIBLE + if (binding.descLinLayout.isVisible) { + // hide the description and chapters + binding.playerDescriptionArrow.animate().rotation(0F).setDuration(250).start() + binding.descLinLayout.visibility = View.GONE + } else { + // show the description and chapters + binding.playerDescriptionArrow.animate().rotation(180F).setDuration(250).start() + binding.descLinLayout.visibility = View.VISIBLE + } + if (this::chapters.isInitialized && chapters.isNotEmpty()) { + val chapterIndex = getCurrentChapterIndex() + // scroll to the current chapter in the chapterRecView in the description + val layoutManager = binding.chaptersRecView.layoutManager as LinearLayoutManager + layoutManager.scrollToPositionWithOffset(chapterIndex, 0) + // set selected + val chaptersAdapter = binding.chaptersRecView.adapter as ChaptersAdapter + chaptersAdapter.updateSelectedPosition(chapterIndex) + } } private fun toggleComments() { @@ -437,12 +750,7 @@ class PlayerFragment : Fragment() { } override fun onPause() { - // pause the player if the screen is turned off - val pausePlayerOnScreenOffEnabled = PreferenceHelper.getBoolean( - requireContext(), - "pause_screen_off", - false - ) + // pauses the player if the screen is turned off // check whether the screen is on val pm = context?.getSystemService(Context.POWER_SERVICE) as PowerManager @@ -461,53 +769,42 @@ class PlayerFragment : Fragment() { super.onDestroy() try { saveWatchPosition() - mediaSession.isActive = false - mediaSession.release() - mediaSessionConnector.setPlayer(null) - playerNotification.setPlayer(null) - val notificationManager = context?.getSystemService( - Context.NOTIFICATION_SERVICE - ) as NotificationManager - notificationManager.cancel(1) - exoPlayer.release() + nowPlayingNotification.destroy() + activity?.requestedOrientation = + if ((activity as MainActivity).autoRotationEnabled) ActivityInfo.SCREEN_ORIENTATION_USER + else ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT } catch (e: Exception) { } } // save the watch position if video isn't finished and option enabled private fun saveWatchPosition() { - val watchPositionsEnabled = PreferenceHelper.getBoolean( - requireContext(), - "watch_positions_toggle", - true - ) if (watchPositionsEnabled && exoPlayer.currentPosition != exoPlayer.duration) { PreferenceHelper.saveWatchPosition( - requireContext(), videoId!!, exoPlayer.currentPosition ) } else if (watchPositionsEnabled) { // delete watch position if video has ended - PreferenceHelper.removeWatchPosition(requireContext(), videoId!!) + PreferenceHelper.removeWatchPosition(videoId!!) } } private fun checkForSegments() { - if (!exoPlayer.isPlaying || !sponsorBlockPrefs.sponsorBlockEnabled) return + if (!exoPlayer.isPlaying || !sponsorBlockEnabled) return - exoPlayerView.postDelayed(this::checkForSegments, 100) + Handler(Looper.getMainLooper()).postDelayed(this::checkForSegments, 100) if (!::segmentData.isInitialized || segmentData.segments.isEmpty()) { return } segmentData.segments.forEach { segment: Segment -> - val segmentStart = (segment.segment!![0] * 1000.0f).toLong() - val segmentEnd = (segment.segment[1] * 1000.0f).toLong() + val segmentStart = (segment.segment!![0] * 1000f).toLong() + val segmentEnd = (segment.segment[1] * 1000f).toLong() val currentPosition = exoPlayer.currentPosition if (currentPosition in segmentStart until segmentEnd) { - if (sponsorBlockPrefs.sponsorNotificationsEnabled) { + if (sponsorBlockNotifications) { Toast.makeText(context, R.string.segment_skipped, Toast.LENGTH_SHORT).show() } exoPlayer.seekTo(segmentEnd) @@ -515,61 +812,101 @@ class PlayerFragment : Fragment() { } } - private fun playVideo(view: View) { - fun run() { - lifecycleScope.launchWhenCreated { - val response = try { - RetrofitInstance.api.getStreams(videoId!!) - } catch (e: IOException) { - println(e) - Log.e(TAG, "IOException, you might not have internet connection") - Toast.makeText(context, R.string.unknown_error, Toast.LENGTH_SHORT).show() - return@launchWhenCreated - } catch (e: HttpException) { - Log.e(TAG, "HttpException, unexpected response") - Toast.makeText(context, R.string.server_error, Toast.LENGTH_SHORT).show() - return@launchWhenCreated - } - // for the notification description adapter - title = response.title!! - uploader = response.uploader!! - thumbnailUrl = response.thumbnailUrl!! + private fun playVideo() { + Globals.playingQueue += videoId!! + lifecycleScope.launchWhenCreated { + streams = try { + RetrofitInstance.api.getStreams(videoId!!) + } catch (e: IOException) { + println(e) + Log.e(TAG, "IOException, you might not have internet connection") + Toast.makeText(context, R.string.unknown_error, Toast.LENGTH_SHORT).show() + return@launchWhenCreated + } catch (e: HttpException) { + Log.e(TAG, "HttpException, unexpected response") + Toast.makeText(context, R.string.server_error, Toast.LENGTH_SHORT).show() + return@launchWhenCreated + } - // save related streams for autoplay - relatedStreams = response.relatedStreams + runOnUiThread { + // set media sources for the player + setResolutionAndSubtitles() + prepareExoPlayerView() + initializePlayerView(streams) + if (!isLive) seekToWatchPosition() + exoPlayer.prepare() + exoPlayer.play() + // show controllers when not in picture in picture mode + if (!activity?.isInPictureInPictureMode!!) exoPlayerView.useController = true + initializePlayerNotification() + if (sponsorBlockEnabled) fetchSponsorBlockSegments() + // show comments if related streams disabled + if (!relatedStreamsEnabled) toggleComments() + // prepare for autoplay + if (autoplayEnabled) setNextStream() + if (watchHistoryEnabled) PreferenceHelper.addToWatchHistory(videoId!!, streams) + } + } + } - runOnUiThread { - // set media sources for the player - setResolutionAndSubtitles(response) - prepareExoPlayerView() - initializePlayerView(view, response) - seekToWatchPosition() - exoPlayer.prepare() - exoPlayer.play() - exoPlayerView.useController = true - initializePlayerNotification(requireContext()) - fetchSponsorBlockSegments() - // show comments if related streams disabled - if (!relatedStreamsEnabled) toggleComments() - // prepare for autoplay - initAutoPlay() - val watchHistoryEnabled = - PreferenceHelper.getBoolean(requireContext(), "Watch_history_toggle", true) - if (watchHistoryEnabled) { - PreferenceHelper.addToWatchHistory(requireContext(), videoId!!, response) - } + /** + * set the videoId of the next stream for autoplay + */ + private fun setNextStream() { + if (!this::autoPlayHelper.isInitialized) autoPlayHelper = AutoPlayHelper(playlistId) + // search for the next videoId in the playlist + lifecycleScope.launchWhenCreated { + nextStreamId = autoPlayHelper.getNextVideoId(videoId!!, streams.relatedStreams!!) + } + } + + /** + * fetch the segments for SponsorBlock + */ + private fun fetchSponsorBlockSegments() { + CoroutineScope(Dispatchers.IO).launch { + kotlin.runCatching { + val categories = PlayerHelper.getSponsorBlockCategories() + if (categories.size > 0) { + segmentData = + RetrofitInstance.api.getSegments( + videoId!!, + ObjectMapper().writeValueAsString(categories) + ) } } } - run() + } + + private fun refreshLiveStatus() { + // switch back to normal speed when on the end of live stream + if (exoPlayer.duration - exoPlayer.currentPosition < 7000) { + exoPlayer.setPlaybackSpeed(1F) + playerBinding.liveSeparator.visibility = View.GONE + playerBinding.liveDiff.text = "" + } else { + Log.e(TAG, "changing the time") + // live stream but not watching at the end/live position + playerBinding.liveSeparator.visibility = View.VISIBLE + val diffText = DateUtils.formatElapsedTime( + (exoPlayer.duration - exoPlayer.currentPosition) / 1000 + ) + playerBinding.liveDiff.text = "-$diffText" + } + // call it again + Handler(Looper.getMainLooper()) + .postDelayed(this@PlayerFragment::refreshLiveStatus, 100) } private fun seekToWatchPosition() { // seek to saved watch position if available - val watchPositions = PreferenceHelper.getWatchPositions(requireContext()) + val watchPositions = PreferenceHelper.getWatchPositions() var position: Long? = null watchPositions.forEach { - if (it.videoId == videoId) position = it.position + if (it.videoId == videoId && + // don't seek to the position if it's the end, autoplay would skip it immediately + streams.duration!! - it.position / 1000 > 2 + ) position = it.position } // support for time stamped links val timeStamp: Long? = arguments?.getLong("timeStamp") @@ -579,141 +916,20 @@ class PlayerFragment : Fragment() { if (position != null) exoPlayer.seekTo(position!!) } - // the function is working recursively - private fun initAutoPlay() { - // save related streams for autoplay - if (autoplay) { - // if it's a playlist use the next video - if (playlistId != null) { - lateinit var playlist: Playlist // var for saving the list in - // runs only the first time when starting a video from a playlist - if (playlistStreamIds.isEmpty()) { - CoroutineScope(Dispatchers.IO).launch { - // fetch the playlists videos - playlist = RetrofitInstance.api.getPlaylist(playlistId!!) - // save the playlist urls in the array - playlist.relatedStreams?.forEach { video -> - playlistStreamIds += video.url?.replace("/watch?v=", "")!! - } - // save playlistNextPage for usage if video is not contained - playlistNextPage = playlist.nextpage - // restart the function after videos are loaded - initAutoPlay() - } - } - // if the playlists contain the video, then save the next video as next stream - else if (playlistStreamIds.contains(videoId)) { - val index = playlistStreamIds.indexOf(videoId) - // check whether there's a next video - if (index + 1 <= playlistStreamIds.size) { - nextStreamId = playlistStreamIds[index + 1] - } - // fetch the next page of the playlist if the video isn't contained - } else if (playlistNextPage != null) { - CoroutineScope(Dispatchers.IO).launch { - RetrofitInstance.api.getPlaylistNextPage(playlistId!!, playlistNextPage!!) - // append all the playlist item urls to the array - playlist.relatedStreams?.forEach { video -> - playlistStreamIds += video.url?.replace("/watch?v=", "")!! - } - // save playlistNextPage for usage if video is not contained - playlistNextPage = playlist.nextpage - // restart the function after videos are loaded - initAutoPlay() - } - } - // else: the video must be the last video of the playlist so nothing happens - - // if it's not a playlist then use the next related video - } else if (relatedStreams != null && relatedStreams!!.isNotEmpty()) { - // save next video from related streams for autoplay - nextStreamId = relatedStreams!![0].url!!.replace("/watch?v=", "") - } - } - } - // used for autoplay and skipping to next video private fun playNextVideo() { + if (nextStreamId == null) return // check whether there is a new video in the queue + val nextQueueVideo = autoPlayHelper.getNextPlayingQueueVideoId(videoId!!) + if (nextQueueVideo != null) nextStreamId = nextQueueVideo // by making sure that the next and the current video aren't the same - if (videoId != nextStreamId) { - // save the id of the next stream as videoId and load the next video - videoId = nextStreamId - playVideo(view!!) - } - } - - private fun setSponsorBlockPrefs() { - sponsorBlockPrefs.sponsorBlockEnabled = - PreferenceHelper.getBoolean(requireContext(), "sb_enabled_key", true) - sponsorBlockPrefs.sponsorNotificationsEnabled = - PreferenceHelper.getBoolean(requireContext(), "sb_notifications_key", true) - sponsorBlockPrefs.introEnabled = - PreferenceHelper.getBoolean(requireContext(), "intro_category_key", false) - sponsorBlockPrefs.selfPromoEnabled = - PreferenceHelper.getBoolean(requireContext(), "selfpromo_category_key", false) - sponsorBlockPrefs.interactionEnabled = - PreferenceHelper.getBoolean(requireContext(), "interaction_category_key", false) - sponsorBlockPrefs.sponsorsEnabled = - PreferenceHelper.getBoolean(requireContext(), "sponsors_category_key", true) - sponsorBlockPrefs.outroEnabled = - PreferenceHelper.getBoolean(requireContext(), "outro_category_key", false) - sponsorBlockPrefs.fillerEnabled = - PreferenceHelper.getBoolean(requireContext(), "filler_category_key", false) - sponsorBlockPrefs.musicOffTopicEnabled = - PreferenceHelper.getBoolean(requireContext(), "music_offtopic_category_key", false) - sponsorBlockPrefs.previewEnabled = - PreferenceHelper.getBoolean(requireContext(), "preview_category_key", false) - } - - private fun fetchSponsorBlockSegments() { - fun run() { - lifecycleScope.launch(Dispatchers.IO) { - if (sponsorBlockPrefs.sponsorBlockEnabled) { - val categories: ArrayList = arrayListOf() - if (sponsorBlockPrefs.introEnabled) { - categories.add("intro") - } - if (sponsorBlockPrefs.selfPromoEnabled) { - categories.add("selfpromo") - } - if (sponsorBlockPrefs.interactionEnabled) { - categories.add("interaction") - } - if (sponsorBlockPrefs.sponsorsEnabled) { - categories.add("sponsor") - } - if (sponsorBlockPrefs.outroEnabled) { - categories.add("outro") - } - if (sponsorBlockPrefs.fillerEnabled) { - categories.add("filler") - } - if (sponsorBlockPrefs.musicOffTopicEnabled) { - categories.add("music_offtopic") - } - if (sponsorBlockPrefs.previewEnabled) { - categories.add("preview") - } - if (categories.size > 0) { - segmentData = try { - RetrofitInstance.api.getSegments( - videoId!!, - "[\"" + TextUtils.join("\",\"", categories) + "\"]" - ) - } catch (e: IOException) { - println(e) - Log.e(TAG, "IOException, you might not have internet connection") - return@launch - } catch (e: HttpException) { - Log.e(TAG, "HttpException, unexpected response") - return@launch - } - } - } - } - } - run() + saveWatchPosition() + // forces the comments to reload for the new video + commentsLoaded = false + binding.commentsRecView.adapter = null + // save the id of the next stream as videoId and load the next video + videoId = nextStreamId + playVideo() } private fun prepareExoPlayerView() { @@ -726,24 +942,49 @@ class PlayerFragment : Fragment() { useController = false player = exoPlayer } + + if (useSystemCaptionStyle) { + // set the subtitle style + val captionStyle = PlayerHelper.getCaptionStyle(requireContext()) + exoPlayerView.subtitleView?.setApplyEmbeddedStyles(captionStyle == CaptionStyleCompat.DEFAULT) + exoPlayerView.subtitleView?.setStyle(captionStyle) + } } - private fun initializePlayerView(view: View, response: Streams) { - binding.playerViewsInfo.text = - context?.getString(R.string.views, response.views.formatShort()) + - " • " + response.uploadDate - binding.textLike.text = response.likes.formatShort() - binding.textDislike.text = response.dislikes.formatShort() - Picasso.get().load(response.uploaderAvatar).into(binding.playerChannelImage) - binding.playerChannelName.text = response.uploader + private fun handleLiveVideo() { + playerBinding.exoTime.visibility = View.GONE + playerBinding.liveLL.visibility = View.VISIBLE + playerBinding.liveIndicator.setOnClickListener { + exoPlayer.seekTo(exoPlayer.duration - 1000) + } + refreshLiveStatus() + } - binding.titleTextView.text = response.title - binding.playerTitle.text = response.title - binding.playerDescription.text = response.description + private fun initializePlayerView(response: Streams) { + binding.apply { + playerViewsInfo.text = + context?.getString(R.string.views, response.views.formatShort()) + + if (!isLive) " • " + response.uploadDate else "" + + textLike.text = response.likes.formatShort() + textDislike.text = response.dislikes.formatShort() + ConnectionHelper.loadImage(response.uploaderAvatar, binding.playerChannelImage) + playerChannelName.text = response.uploader + + titleTextView.text = response.title + + playerTitle.text = response.title + playerDescription.text = response.description + } + + // duration that's not greater than 0 indicates that the video is live + if (response.duration!! <= 0) { + isLive = true + handleLiveVideo() + } playerBinding.exoTitle.text = response.title - enableSeekbarPreview() enableDoubleTapToSeek() // init the chapters recyclerview @@ -752,34 +993,11 @@ class PlayerFragment : Fragment() { initializeChapters() } - // set default playback speed - val playbackSpeed = - PreferenceHelper.getString(requireContext(), "playback_speed", "1F")!! - val playbackSpeeds = context?.resources?.getStringArray(R.array.playbackSpeed)!! - val playbackSpeedValues = - context?.resources?.getStringArray(R.array.playbackSpeedValues)!! - exoPlayer.setPlaybackSpeed(playbackSpeed.toFloat()) - val speedIndex = playbackSpeedValues.indexOf(playbackSpeed) - playerBinding.speedText.text = playbackSpeeds[speedIndex] - - // change playback speed button - playerBinding.speedText.setOnClickListener { - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.change_playback_speed) - .setItems(playbackSpeeds) { _, index -> - // set the new playback speed - val newPlaybackSpeed = playbackSpeedValues[index].toFloat() - exoPlayer.setPlaybackSpeed(newPlaybackSpeed) - playerBinding.speedText.text = playbackSpeeds[index] - } - .show() - } - // Listener for play and pause icon change exoPlayer.addListener(object : Player.Listener { override fun onIsPlayingChanged(isPlaying: Boolean) { - if (isPlaying && sponsorBlockPrefs.sponsorBlockEnabled) { - exoPlayerView.postDelayed( + if (isPlaying && sponsorBlockEnabled) { + Handler(Looper.getMainLooper()).postDelayed( this@PlayerFragment::checkForSegments, 100 ) @@ -818,11 +1036,11 @@ class PlayerFragment : Fragment() { playbackState == Player.STATE_ENDED && nextStreamId != null && !transitioning && - autoplay + autoplayEnabled ) { transitioning = true // check whether autoplay is enabled - if (autoplay) playNextVideo() + if (autoplayEnabled) playNextVideo() } if (playWhenReady && playbackState == Player.STATE_READY) { @@ -841,47 +1059,16 @@ class PlayerFragment : Fragment() { } }) - // repeat toggle button - playerBinding.repeatToggle.setOnClickListener { - if (exoPlayer.repeatMode == RepeatModeUtil.REPEAT_TOGGLE_MODE_ALL) { - // turn off repeat mode - exoPlayer.repeatMode = RepeatModeUtil.REPEAT_TOGGLE_MODE_NONE - playerBinding.repeatToggle.setColorFilter(Color.GRAY) - } else { - exoPlayer.repeatMode = RepeatModeUtil.REPEAT_TOGGLE_MODE_ALL - playerBinding.repeatToggle.setColorFilter(Color.WHITE) - } - } - - // share button - binding.relPlayerShare.setOnClickListener { - val shareDialog = ShareDialog(videoId!!, false) - shareDialog.show(childFragmentManager, "ShareDialog") - } - - binding.relPlayerBackground.setOnClickListener { - // pause the current player - exoPlayer.pause() - - // start the background mode - BackgroundMode - .getInstance() - .playOnBackgroundMode( - requireContext(), - videoId!! - ) - } - // check if livestream - if (response.duration!! > 0) { + if (response.duration > 0) { // download clicked binding.relPlayerDownload.setOnClickListener { - if (!IS_DOWNLOAD_RUNNING) { + if (!Globals.IS_DOWNLOAD_RUNNING) { val newFragment = DownloadDialog() val bundle = Bundle() bundle.putString("video_id", videoId) newFragment.arguments = bundle - newFragment.show(childFragmentManager, "DownloadDialog") + newFragment.show(childFragmentManager, DownloadDialog::class.java.name) } else { Toast.makeText(context, R.string.dlisinprogress, Toast.LENGTH_SHORT) .show() @@ -892,19 +1079,23 @@ class PlayerFragment : Fragment() { } if (response.hls != null) { - binding.relPlayerVlc.setOnClickListener { + binding.relPlayerOpen.setOnClickListener { // start an intent with video as mimetype using the hls stream val uri: Uri = Uri.parse(response.hls) val intent = Intent() intent.action = Intent.ACTION_VIEW intent.setDataAndType(uri, "video/*") - intent.putExtra(Intent.EXTRA_TITLE, title) - intent.putExtra("title", title) - intent.putExtra("artist", uploader) + intent.putExtra(Intent.EXTRA_TITLE, streams.title) + intent.putExtra("title", streams.title) + intent.putExtra("artist", streams.uploader) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - startActivity(intent) + try { + startActivity(intent) + } catch (e: Exception) { + Toast.makeText(context, R.string.no_player_found, Toast.LENGTH_SHORT).show() + } } } if (relatedStreamsEnabled) { @@ -930,104 +1121,119 @@ class PlayerFragment : Fragment() { } binding.playerChannel.setOnClickListener { - val activity = view.context as MainActivity + val activity = view?.context as MainActivity val bundle = bundleOf("channel_id" to response.uploaderUrl) activity.navController.navigate(R.id.channelFragment, bundle) activity.binding.mainMotionLayout.transitionToEnd() binding.playerMotionLayout.transitionToEnd() } - val token = PreferenceHelper.getToken(requireContext()) if (token != "") { - val channelId = response.uploaderUrl?.replace("/channel/", "") - isSubscribed(binding.playerSubscribe, channelId!!) + isSubscribed() binding.relPlayerSave.setOnClickListener { - val newFragment = AddtoPlaylistDialog() + val newFragment = AddToPlaylistDialog() val bundle = Bundle() bundle.putString("videoId", videoId) newFragment.arguments = bundle - newFragment.show(childFragmentManager, "AddToPlaylist") + newFragment.show(childFragmentManager, AddToPlaylistDialog::class.java.name) } + } else { + binding.relPlayerSave.setOnClickListener { + Toast.makeText(context, R.string.login_first, Toast.LENGTH_SHORT).show() + } + } + + // next and previous buttons + playerBinding.skipPrev.visibility = if ( + skipButtonsEnabled && Globals.playingQueue.indexOf(videoId!!) != 0 + ) View.VISIBLE else View.INVISIBLE + playerBinding.skipNext.visibility = if (skipButtonsEnabled) View.VISIBLE else View.INVISIBLE + + playerBinding.skipPrev.setOnClickListener { + val index = Globals.playingQueue.indexOf(videoId!!) - 1 + videoId = Globals.playingQueue[index] + playVideo() + } + + playerBinding.skipNext.setOnClickListener { + playNextVideo() } } private fun enableDoubleTapToSeek() { - val seekIncrement = - PreferenceHelper.getString(requireContext(), "seek_increment", "5")?.toLong()!! * 1000 - - // enable rewind button - binding.rewindFL.setOnClickListener( - DoubleClickListener( - callback = object : DoubleClickListener.Callback { - override fun doubleClicked() { - binding.rewindBTN.visibility = View.VISIBLE - exoPlayer.seekTo(exoPlayer.currentPosition - seekIncrement) - Handler(Looper.getMainLooper()).postDelayed({ - binding.rewindBTN.visibility = View.INVISIBLE - }, 700) - } - - override fun singleClicked() { - toggleController() + // set seek increment text + val seekIncrementText = (seekIncrement / 1000).toString() + doubleTapOverlayBinding.rewindTV.text = seekIncrementText + doubleTapOverlayBinding.forwardTV.text = seekIncrementText + binding.player.setOnDoubleTapListener( + object : DoubleTapInterface { + override fun onEvent(x: Float) { + val width = exoPlayerView.width + when { + width * 0.5 > x -> rewind() + width * 0.5 < x -> forward() } } - ) - ) - - // enable fast forward button - binding.forwardFL.setOnClickListener( - DoubleClickListener( - callback = object : DoubleClickListener.Callback { - override fun doubleClicked() { - binding.forwardBTN.visibility = View.VISIBLE - exoPlayer.seekTo(exoPlayer.currentPosition + seekIncrement) - Handler(Looper.getMainLooper()).postDelayed({ - binding.forwardBTN.visibility = View.INVISIBLE - }, 700) - } - - override fun singleClicked() { - toggleController() - } - } - ) + } ) } - private fun disableDoubleTapToSeek() { - // disable fast forward and rewind by double tapping - binding.forwardFL.visibility = View.GONE - binding.rewindFL.visibility = View.GONE + private fun rewind() { + exoPlayer.seekTo(exoPlayer.currentPosition - seekIncrement) + + // show the rewind button + doubleTapOverlayBinding.rewindBTN.apply { + visibility = View.VISIBLE + // clear previous animation + animate().rotation(0F).setDuration(0).start() + // start new animation + animate() + .rotation(-30F) + .setDuration(100) + .withEndAction { + // reset the animation when finished + animate().rotation(0F).setDuration(100).start() + } + .start() + + removeCallbacks(hideRewindButtonRunnable) + // start callback to hide the button + postDelayed(hideRewindButtonRunnable, 700) + } } - // toggle the visibility of the player controller - private fun toggleController() { - if (exoPlayerView.isControllerFullyVisible) exoPlayerView.hideController() - else exoPlayerView.showController() + private fun forward() { + exoPlayer.seekTo(exoPlayer.currentPosition + seekIncrement) + + // show the forward button + doubleTapOverlayBinding.forwardBTN.apply { + visibility = View.VISIBLE + // clear previous animation + animate().rotation(0F).setDuration(0).start() + // start new animation + animate() + .rotation(30F) + .setDuration(100) + .withEndAction { + // reset the animation when finished + animate().rotation(0F).setDuration(100).start() + } + .start() + + // start callback to hide the button + removeCallbacks(hideForwardButtonRunnable) + postDelayed(hideForwardButtonRunnable, 700) + } } - // enable seek bar preview - private fun enableSeekbarPreview() { - playerBinding.exoProgress.addListener(object : TimeBar.OnScrubListener { - override fun onScrubStart(timeBar: TimeBar, position: Long) { - exoPlayer.pause() - } - - override fun onScrubMove(timeBar: TimeBar, position: Long) { - val minTimeDiff = 10 * 1000 // 10s - // get the difference between the new and the old position - val diff = abs(exoPlayer.currentPosition - position) - // seek only when the difference is greater than 10 seconds - if (diff >= minTimeDiff) exoPlayer.seekTo(position) - } - - override fun onScrubStop(timeBar: TimeBar, position: Long, canceled: Boolean) { - exoPlayer.seekTo(position) - exoPlayer.play() - Handler(Looper.getMainLooper()).postDelayed({ - exoPlayerView.hideController() - }, 200) - } - }) + private val hideForwardButtonRunnable = Runnable { + doubleTapOverlayBinding.forwardBTN.apply { + visibility = View.GONE + } + } + private val hideRewindButtonRunnable = Runnable { + doubleTapOverlayBinding.rewindBTN.apply { + visibility = View.GONE + } } private fun initializeChapters() { @@ -1049,7 +1255,7 @@ class PlayerFragment : Fragment() { } playerBinding.chapterLL.visibility = View.VISIBLE playerBinding.chapterLL.setOnClickListener { - if (Globals.isFullScreen) { + if (Globals.IS_FULL_SCREEN) { MaterialAlertDialogBuilder(requireContext()) .setTitle(R.string.chapters) .setItems(titles.toTypedArray()) { _, index -> @@ -1070,27 +1276,31 @@ class PlayerFragment : Fragment() { // call the function again in 100ms exoPlayerView.postDelayed(this::setCurrentChapterName, 100) - val chapterName = getCurrentChapterName() + val chapterIndex = getCurrentChapterIndex() + val chapterName = chapters[chapterIndex].title // change the chapter name textView text to the chapterName - if (chapterName != null && chapterName != playerBinding.chapterName.text) { + if (chapterName != playerBinding.chapterName.text) { playerBinding.chapterName.text = chapterName + // update the selected item + val chaptersAdapter = binding.chaptersRecView.adapter as ChaptersAdapter + chaptersAdapter.updateSelectedPosition(chapterIndex) } } // get the name of the currently played chapter - private fun getCurrentChapterName(): String? { + private fun getCurrentChapterIndex(): Int { val currentPosition = exoPlayer.currentPosition - var chapterName: String? = null + var chapterIndex = 0 - chapters.forEach { + chapters.forEachIndexed { index, chapter -> // check whether the chapter start is greater than the current player position - if (currentPosition >= it.start!! * 1000) { + if (currentPosition >= chapter.start!! * 1000) { // save chapter title if found - chapterName = it.title + chapterIndex = index } } - return chapterName + return chapterIndex } private fun setMediaSource( @@ -1114,22 +1324,22 @@ class PlayerFragment : Fragment() { exoPlayer.setMediaSource(mergeSource) } - private fun setResolutionAndSubtitles(response: Streams) { - val videoFormatPreference = - PreferenceHelper.getString(requireContext(), "player_video_format", "WEBM") + private fun getAvailableResolutions(): Pair, Array> { + if (!this::streams.isInitialized) return Pair(arrayOf(), arrayOf()) - var videosNameArray: Array = arrayOf() + var videosNameArray: Array = arrayOf() var videosUrlArray: Array = arrayOf() // append hls to list if available - if (response.hls != null) { + if (streams.hls != null) { videosNameArray += getString(R.string.hls) - videosUrlArray += response.hls.toUri() + videosUrlArray += streams.hls!!.toUri() } - for (vid in response.videoStreams!!) { + for (vid in streams.videoStreams!!) { // append quality to list if it has the preferred format (e.g. MPEG) - if (vid.format.equals(videoFormatPreference) && vid.url != null) { // preferred format + val preferredMimeType = "video/$videoFormatPreference" + if (vid.url != null && vid.mimeType == preferredMimeType) { // preferred format videosNameArray += vid.quality.toString() videosUrlArray += vid.url!!.toUri() } else if (vid.quality.equals("LBRY") && vid.format.equals("MP4")) { // LBRY MP4 format @@ -1137,76 +1347,56 @@ class PlayerFragment : Fragment() { videosUrlArray += vid.url!!.toUri() } } + return Pair(videosNameArray, videosUrlArray) + } + + private fun setResolutionAndSubtitles() { + // get the available resolutions + val (videosNameArray, videosUrlArray) = getAvailableResolutions() + // create a list of subtitles - subtitle = mutableListOf() - response.subtitles!!.forEach { + subtitle = mutableListOf() + val subtitlesNamesList = mutableListOf(context?.getString(R.string.none)!!) + val subtitleCodesList = mutableListOf("") + streams.subtitles!!.forEach { subtitle.add( SubtitleConfiguration.Builder(it.url!!.toUri()) .setMimeType(it.mimeType!!) // The correct MIME type (required). .setLanguage(it.code) // The subtitle language (optional). .build() ) + subtitlesNamesList += it.name!! + subtitleCodesList += it.code!! } + + // set the default subtitle if available + if (defaultSubtitleCode != "" && subtitleCodesList.contains(defaultSubtitleCode)) { + val newParams = trackSelector.buildUponParameters() + .setPreferredTextLanguage(defaultSubtitleCode) + .setPreferredTextRoleFlags(C.ROLE_FLAG_CAPTION) + trackSelector.setParameters(newParams) + } + // set media source and resolution in the beginning setStreamSource( - response, + streams, videosNameArray, videosUrlArray ) - - playerBinding.qualityText.setOnClickListener { - // Dialog for quality selection - val builder: MaterialAlertDialogBuilder? = activity?.let { - MaterialAlertDialogBuilder(it) - } - val lastPosition = exoPlayer.currentPosition - builder!!.setTitle(R.string.choose_quality_dialog) - .setItems( - videosNameArray - ) { _, which -> - whichQuality = which - if ( - videosNameArray[which] == getString(R.string.hls) || - videosNameArray[which] == "LBRY HLS" - ) { - // no need to merge sources if using hls - val mediaItem: MediaItem = MediaItem.Builder() - .setUri(videosUrlArray[which]) - .setSubtitleConfigurations(subtitle) - .build() - exoPlayer.setMediaItem(mediaItem) - } else { - val videoUri = videosUrlArray[which] - val audioUrl = getMostBitRate(response.audioStreams!!) - setMediaSource(videoUri, audioUrl) - } - exoPlayer.seekTo(lastPosition) - playerBinding.qualityText.text = videosNameArray[which] - } - val dialog = builder.create() - dialog.show() - } } private fun setStreamSource( streams: Streams, - videosNameArray: Array, + videosNameArray: Array, videosUrlArray: Array ) { - val defRes = PreferenceHelper.getString( - requireContext(), - "default_resolution", - "hls" - )!! - - if (defRes != "hls") { + if (defRes != "") { videosNameArray.forEachIndexed { index, pipedStream -> // search for quality preference in the available stream sources if (pipedStream.contains(defRes)) { val videoUri = videosUrlArray[index] - val audioUrl = getMostBitRate(streams.audioStreams!!) + val audioUrl = PlayerHelper.getAudioSource(streams.audioStreams!!) setMediaSource(videoUri, audioUrl) - playerBinding.qualityText.text = videosNameArray[index] return } } @@ -1219,23 +1409,18 @@ class PlayerFragment : Fragment() { .setSubtitleConfigurations(subtitle) .build() exoPlayer.setMediaItem(mediaItem) - playerBinding.qualityText.text = context?.getString(R.string.hls) return } // if nothing found, use the first list entry if (videosUrlArray.isNotEmpty()) { val videoUri = videosUrlArray[0] - val audioUrl = getMostBitRate(streams.audioStreams!!) + val audioUrl = PlayerHelper.getAudioSource(streams.audioStreams!!) setMediaSource(videoUri, audioUrl) - playerBinding.qualityText.text = videosNameArray[0] } } - private fun createExoPlayer(view: View) { - val bufferingGoal = - PreferenceHelper.getString(requireContext(), "buffering_goal", "50")?.toInt()!! * 1000 - + private fun createExoPlayer() { val cronetEngine: CronetEngine = CronetHelper.getCronetEngine() val cronetDataSourceFactory: CronetDataSource.Factory = CronetDataSource.Factory(cronetEngine, Executors.newCachedThreadPool()) @@ -1263,86 +1448,73 @@ class PlayerFragment : Fragment() { ) .build() - exoPlayer = ExoPlayer.Builder(view.context) + // control for the track sources like subtitles and audio source + trackSelector = DefaultTrackSelector(requireContext()) + + exoPlayer = ExoPlayer.Builder(requireContext()) .setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory)) .setLoadControl(loadControl) + .setTrackSelector(trackSelector) .build() exoPlayer.setAudioAttributes(audioAttributes, true) } - private fun initializePlayerNotification(c: Context) { - mediaSession = MediaSessionCompat(c, this.javaClass.name) - mediaSession.apply { - isActive = true - } - - mediaSessionConnector = MediaSessionConnector(mediaSession) - mediaSessionConnector.setPlayer(exoPlayer) - - playerNotification = PlayerNotificationManager - .Builder(c, 1, "background_mode") - .setMediaDescriptionAdapter( - DescriptionAdapter(title, uploader, thumbnailUrl, requireContext()) - ) - .build() - - playerNotification.apply { - setPlayer(exoPlayer) - setUsePreviousAction(false) - setUseStopAction(true) - setMediaSessionToken(mediaSession.sessionToken) + /** + * show the [NowPlayingNotification] for the current video + */ + private fun initializePlayerNotification() { + if (!this::nowPlayingNotification.isInitialized) { + nowPlayingNotification = NowPlayingNotification(requireContext(), exoPlayer) } + nowPlayingNotification.updatePlayerNotification(streams) } // lock the player private fun lockPlayer(isLocked: Boolean) { + // isLocked is the current (old) state of the player lock val visibility = if (isLocked) View.VISIBLE else View.GONE playerBinding.exoTopBarRight.visibility = visibility - playerBinding.exoPlayPause.visibility = visibility + playerBinding.exoCenterControls.visibility = visibility playerBinding.exoBottomBar.visibility = visibility playerBinding.closeImageButton.visibility = visibility playerBinding.exoTitle.visibility = - if (isLocked && Globals.isFullScreen) View.VISIBLE else View.INVISIBLE + if (isLocked && + Globals.IS_FULL_SCREEN + ) View.VISIBLE else View.INVISIBLE // disable double tap to seek when the player is locked - if (isLocked) enableDoubleTapToSeek() else disableDoubleTapToSeek() + if (isLocked) { + // enable fast forward and rewind by double tapping + enableDoubleTapToSeek() + } else { + // disable fast forward and rewind by double tapping + binding.player.setOnDoubleTapListener(null) + } } - private fun isSubscribed(button: MaterialButton, channel_id: String) { - @SuppressLint("ResourceAsColor") + private fun isSubscribed() { fun run() { + val channelId = streams.uploaderUrl.toID() lifecycleScope.launchWhenCreated { - val response = try { - val token = PreferenceHelper.getToken(requireContext()) - RetrofitInstance.authApi.isSubscribed( - channel_id, - token - ) - } catch (e: IOException) { - println(e) - Log.e(TAG, "IOException, you might not have internet connection") - return@launchWhenCreated - } catch (e: HttpException) { - Log.e(TAG, "HttpException, unexpected response") - return@launchWhenCreated - } + isSubscribed = SubscriptionHelper.isSubscribed(channelId) + + if (isSubscribed == null) return@launchWhenCreated runOnUiThread { - if (response.subscribed == true) { - isSubscribed = true - button.text = getString(R.string.unsubscribe) + if (isSubscribed == true) { + binding.playerSubscribe.text = getString(R.string.unsubscribe) } - if (response.subscribed != null) { - button.setOnClickListener { - if (isSubscribed) { - unsubscribe(channel_id) - button.text = getString(R.string.subscribe) - } else { - subscribe(channel_id) - button.text = getString(R.string.unsubscribe) - } + binding.playerSubscribe.setOnClickListener { + if (isSubscribed == true) { + SubscriptionHelper.unsubscribe(channelId) + binding.playerSubscribe.text = getString(R.string.subscribe) + isSubscribed = false + } else { + SubscriptionHelper.subscribe(channelId) + binding.playerSubscribe.text = getString(R.string.unsubscribe) + isSubscribed = true } } } @@ -1351,71 +1523,6 @@ class PlayerFragment : Fragment() { run() } - private fun subscribe(channel_id: String) { - fun run() { - lifecycleScope.launchWhenCreated { - try { - val token = PreferenceHelper.getToken(requireContext()) - RetrofitInstance.authApi.subscribe( - token, - Subscribe(channel_id) - ) - } catch (e: IOException) { - println(e) - Log.e(TAG, "IOException, you might not have internet connection") - return@launchWhenCreated - } catch (e: HttpException) { - Log.e(TAG, "HttpException, unexpected response$e") - return@launchWhenCreated - } - isSubscribed = true - } - } - run() - } - - private fun unsubscribe(channel_id: String) { - fun run() { - lifecycleScope.launchWhenCreated { - try { - val token = PreferenceHelper.getToken(requireContext()) - RetrofitInstance.authApi.unsubscribe( - token, - Subscribe(channel_id) - ) - } catch (e: IOException) { - println(e) - Log.e(TAG, "IOException, you might not have internet connection") - return@launchWhenCreated - } catch (e: HttpException) { - Log.e(TAG, "HttpException, unexpected response") - return@launchWhenCreated - } - isSubscribed = false - } - } - run() - } - - private fun Fragment?.runOnUiThread(action: () -> Unit) { - this ?: return - if (!isAdded) return // Fragment not attached to an Activity - activity?.runOnUiThread(action) - } - - private fun getMostBitRate(audios: List): String { - var bitrate = 0 - var index = 0 - for ((i, audio) in audios.withIndex()) { - val q = audio.quality!!.replace(" kbps", "").toInt() - if (q > bitrate) { - bitrate = q - index = i - } - } - return audios[index].url!! - } - private fun fetchComments() { lifecycleScope.launchWhenCreated { val commentsResponse = try { @@ -1461,31 +1568,57 @@ class PlayerFragment : Fragment() { override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) { super.onPictureInPictureModeChanged(isInPictureInPictureMode) if (isInPictureInPictureMode) { + // set portrait mode + unsetFullscreen() + // hide and disable exoPlayer controls exoPlayerView.hideController() exoPlayerView.useController = false - unsetFullscreen() + with(binding.playerMotionLayout) { + getConstraintSet(R.id.start).constrainHeight(R.id.player, -1) + enableTransition(R.id.yt_transition, false) + } + binding.linLayout.visibility = View.GONE - Globals.isFullScreen = false + Globals.IS_FULL_SCREEN = false } else { // enable exoPlayer controls again exoPlayerView.useController = true - // switch back to portrait mode - unsetFullscreen() + with(binding.playerMotionLayout) { + getConstraintSet(R.id.start).constrainHeight(R.id.player, 0) + enableTransition(R.id.yt_transition, true) + } + binding.linLayout.visibility = View.VISIBLE } } fun onUserLeaveHint() { + if (SDK_INT >= Build.VERSION_CODES.O && shouldStartPiP()) { + activity?.enterPictureInPictureMode(updatePipParams()) + } + } + + private fun shouldStartPiP(): Boolean { val bounds = Rect() binding.playerScrollView.getHitRect(bounds) - if (SDK_INT >= Build.VERSION_CODES.O && - exoPlayer.isPlaying && (binding.playerScrollView.getLocalVisibleRect(bounds) || Globals.isFullScreen) - ) { - activity?.enterPictureInPictureMode(updatePipParams()) + val backgroundModeRunning = isServiceRunning(requireContext(), BackgroundMode::class.java) + + return (binding.playerScrollView.getLocalVisibleRect(bounds) || Globals.IS_FULL_SCREEN) && + (exoPlayer.isPlaying || !backgroundModeRunning) + } + + private fun isServiceRunning(context: Context, serviceClass: Class<*>): Boolean { + val manager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + @Suppress("DEPRECATION") + for (service in manager.getRunningServices(Int.MAX_VALUE)) { + if (serviceClass.name == service.service.className) { + return true + } } + return false } private fun updatePipParams() = PictureInPictureParams.Builder() diff --git a/app/src/main/java/com/github/libretube/fragments/PlaylistFragment.kt b/app/src/main/java/com/github/libretube/fragments/PlaylistFragment.kt index 57ac348a5..0aa9a15e4 100644 --- a/app/src/main/java/com/github/libretube/fragments/PlaylistFragment.kt +++ b/app/src/main/java/com/github/libretube/fragments/PlaylistFragment.kt @@ -5,24 +5,27 @@ import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView import com.github.libretube.R import com.github.libretube.adapters.PlaylistAdapter import com.github.libretube.databinding.FragmentPlaylistBinding import com.github.libretube.dialogs.PlaylistOptionsDialog -import com.github.libretube.preferences.PreferenceHelper +import com.github.libretube.extensions.BaseFragment import com.github.libretube.util.RetrofitInstance +import com.github.libretube.util.toID import retrofit2.HttpException import java.io.IOException -class PlaylistFragment : Fragment() { +class PlaylistFragment : BaseFragment() { private val TAG = "PlaylistFragment" private lateinit var binding: FragmentPlaylistBinding private var playlistId: String? = null - var nextPage: String? = null + private var isOwner: Boolean = false + private var nextPage: String? = null private var playlistAdapter: PlaylistAdapter? = null private var isLoading = true @@ -30,6 +33,7 @@ class PlaylistFragment : Fragment() { super.onCreate(savedInstanceState) arguments?.let { playlistId = it.getString("playlist_id") + isOwner = it.getBoolean("isOwner") } } @@ -45,7 +49,7 @@ class PlaylistFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - playlistId = playlistId!!.replace("/playlist?list=", "") + playlistId = playlistId!!.toID() binding.playlistRecView.layoutManager = LinearLayoutManager(context) binding.playlistProgress.visibility = View.VISIBLE @@ -56,7 +60,9 @@ class PlaylistFragment : Fragment() { fun run() { lifecycleScope.launchWhenCreated { val response = try { - RetrofitInstance.api.getPlaylist(playlistId!!) + // load locally stored playlists with the auth api + if (isOwner) RetrofitInstance.authApi.getPlaylist(playlistId!!) + else RetrofitInstance.api.getPlaylist(playlistId!!) } catch (e: IOException) { println(e) Log.e(TAG, "IOException, you might not have internet connection") @@ -70,20 +76,18 @@ class PlaylistFragment : Fragment() { runOnUiThread { binding.playlistProgress.visibility = View.GONE binding.playlistName.text = response.name - binding.playlistUploader.text = response.uploader - binding.playlistTotVideos.text = + binding.uploader.text = response.uploader + binding.videoCount.text = getString(R.string.videoCount, response.videos.toString()) - val user = PreferenceHelper.getUsername(requireContext()) - // check whether the user owns the playlist - val isOwner = response.uploaderUrl == null && - response.uploader.equals(user, true) - // show playlist options binding.optionsMenu.setOnClickListener { val optionsDialog = - PlaylistOptionsDialog(playlistId!!, isOwner, requireContext()) - optionsDialog.show(childFragmentManager, "PlaylistOptionsDialog") + PlaylistOptionsDialog(playlistId!!, isOwner) + optionsDialog.show( + childFragmentManager, + PlaylistOptionsDialog::class.java.name + ) } playlistAdapter = PlaylistAdapter( @@ -93,6 +97,16 @@ class PlaylistFragment : Fragment() { requireActivity(), childFragmentManager ) + + // listen for playlist items to become deleted + playlistAdapter!!.registerAdapterDataObserver(object : + RecyclerView.AdapterDataObserver() { + override fun onChanged() { + binding.videoCount.text = + getString(R.string.videoCount, playlistAdapter!!.itemCount.toString()) + } + }) + binding.playlistRecView.adapter = playlistAdapter binding.playlistScrollview.viewTreeObserver .addOnScrollChangedListener { @@ -104,10 +118,37 @@ class PlaylistFragment : Fragment() { isLoading = true fetchNextPage() } - } else { - // scroll view is not at bottom } } + + /** + * listener for swiping to the left or right + */ + if (isOwner) { + val itemTouchCallback = object : ItemTouchHelper.SimpleCallback( + 0, + ItemTouchHelper.LEFT + ) { + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder + ): Boolean { + return false + } + + override fun onSwiped( + viewHolder: RecyclerView.ViewHolder, + direction: Int + ) { + val position = viewHolder.absoluteAdapterPosition + playlistAdapter!!.removeFromPlaylist(position) + } + } + + val itemTouchHelper = ItemTouchHelper(itemTouchCallback) + itemTouchHelper.attachToRecyclerView(binding.playlistRecView) + } } } } @@ -118,7 +159,14 @@ class PlaylistFragment : Fragment() { fun run() { lifecycleScope.launchWhenCreated { val response = try { - RetrofitInstance.api.getPlaylistNextPage(playlistId!!, nextPage!!) + // load locally stored playlists with the auth api + if (isOwner) RetrofitInstance.authApi.getPlaylistNextPage( + playlistId!!, + nextPage!! + ) else RetrofitInstance.api.getPlaylistNextPage( + playlistId!!, + nextPage!! + ) } catch (e: IOException) { println(e) Log.e(TAG, "IOException, you might not have internet connection") @@ -134,10 +182,4 @@ class PlaylistFragment : Fragment() { } run() } - - private fun Fragment?.runOnUiThread(action: () -> Unit) { - this ?: return - if (!isAdded) return // Fragment not attached to an Activity - activity?.runOnUiThread(action) - } } diff --git a/app/src/main/java/com/github/libretube/fragments/SearchFragment.kt b/app/src/main/java/com/github/libretube/fragments/SearchFragment.kt index 1780eca4c..ca133d4e2 100644 --- a/app/src/main/java/com/github/libretube/fragments/SearchFragment.kt +++ b/app/src/main/java/com/github/libretube/fragments/SearchFragment.kt @@ -1,52 +1,34 @@ package com.github.libretube.fragments -import android.content.Context import android.os.Bundle -import android.text.Editable -import android.text.TextWatcher import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.view.WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN -import android.view.inputmethod.EditorInfo -import android.view.inputmethod.InputMethodManager -import android.widget.EditText -import android.widget.TextView.GONE -import android.widget.TextView.OnEditorActionListener -import android.widget.TextView.VISIBLE -import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager -import com.github.libretube.R -import com.github.libretube.activities.hideKeyboard -import com.github.libretube.adapters.SearchAdapter +import com.github.libretube.activities.MainActivity import com.github.libretube.adapters.SearchHistoryAdapter import com.github.libretube.adapters.SearchSuggestionsAdapter import com.github.libretube.databinding.FragmentSearchBinding +import com.github.libretube.extensions.BaseFragment +import com.github.libretube.models.SearchViewModel import com.github.libretube.preferences.PreferenceHelper import com.github.libretube.util.RetrofitInstance -import com.google.android.material.dialog.MaterialAlertDialogBuilder import retrofit2.HttpException import java.io.IOException -class SearchFragment : Fragment() { +class SearchFragment() : BaseFragment() { private val TAG = "SearchFragment" private lateinit var binding: FragmentSearchBinding + private val viewModel: SearchViewModel by activityViewModels() - private var selectedFilter = 0 - private var apiSearchFilter = "all" - private var nextPage: String? = null - - private var searchAdapter: SearchAdapter? = null - private var isLoading: Boolean = true - private var isFetchingSearch: Boolean = false + private var query: String? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - arguments?.let { - } + query = arguments?.getString("query") } override fun onCreateView( @@ -61,109 +43,25 @@ class SearchFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - var tempSelectedItem = 0 + binding.suggestionsRecycler.layoutManager = LinearLayoutManager(requireContext()) - binding.clearSearchImageView.setOnClickListener { - binding.autoCompleteTextView.text.clear() + // waiting for the query to change + viewModel.searchQuery.observe(viewLifecycleOwner) { + showData(it) } - - binding.filterMenuImageView.setOnClickListener { - val filterOptions = arrayOf( - getString(R.string.all), - getString(R.string.videos), - getString(R.string.channels), - getString(R.string.playlists), - getString(R.string.music_songs), - getString(R.string.music_videos), - getString(R.string.music_albums), - getString(R.string.music_playlists) - ) - - MaterialAlertDialogBuilder(view.context) - .setTitle(getString(R.string.choose_filter)) - .setSingleChoiceItems(filterOptions, selectedFilter) { _, id -> - tempSelectedItem = id - } - .setPositiveButton( - getString(R.string.okay) - ) { _, _ -> - selectedFilter = tempSelectedItem - apiSearchFilter = when (selectedFilter) { - 0 -> "all" - 1 -> "videos" - 2 -> "channels" - 3 -> "playlists" - 4 -> "music_songs" - 5 -> "music_videos" - 6 -> "music_albums" - 7 -> "music_playlists" - else -> "all" - } - fetchSearch(binding.autoCompleteTextView.text.toString()) - } - .setNegativeButton(getString(R.string.cancel), null) - .create() - .show() - } - - // show search history - binding.historyRecycler.layoutManager = LinearLayoutManager(view.context) - showHistory() - - binding.searchRecycler.layoutManager = GridLayoutManager(view.context, 1) - binding.autoCompleteTextView.requestFocus() - val imm = - requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - imm.showSoftInput(binding.autoCompleteTextView, InputMethodManager.SHOW_IMPLICIT) - - binding.autoCompleteTextView.addTextChangedListener(object : TextWatcher { - override fun beforeTextChanged( - s: CharSequence?, - start: Int, - count: Int, - after: Int - ) { - } - - override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { - if (s!! != "") { - binding.searchRecycler.adapter = null - - binding.searchRecycler.viewTreeObserver - .addOnScrollChangedListener { - if (!binding.searchRecycler.canScrollVertically(1)) { - fetchNextSearchItems(binding.autoCompleteTextView.text.toString()) - } - } - fetchSuggestions(s.toString(), binding.autoCompleteTextView) - } - } - - override fun afterTextChanged(s: Editable?) { - if (s!!.isEmpty()) { - showHistory() - } - } - }) - binding.autoCompleteTextView.setOnEditorActionListener( - OnEditorActionListener { _, actionId, _ -> - if (actionId == EditorInfo.IME_ACTION_SEARCH) { - hideKeyboard() - binding.searchRecycler.visibility = VISIBLE - binding.historyRecycler.visibility = GONE - fetchSearch(binding.autoCompleteTextView.text.toString()) - return@OnEditorActionListener true - } - false - } - ) } - private fun fetchSuggestions(query: String, autoTextView: EditText) { + private fun showData(query: String?) { + // fetch the search or history + binding.historyEmpty.visibility = View.GONE + binding.suggestionsRecycler.visibility = View.VISIBLE + if (query == null || query == "") showHistory() + else fetchSuggestions(query) + } + + private fun fetchSuggestions(query: String) { fun run() { lifecycleScope.launchWhenCreated { - binding.searchRecycler.visibility = GONE - binding.historyRecycler.visibility = VISIBLE val response = try { RetrofitInstance.api.getSuggestions(query) } catch (e: IOException) { @@ -174,118 +72,33 @@ class SearchFragment : Fragment() { Log.e(TAG, "HttpException, unexpected response") return@launchWhenCreated } + // only load the suggestions if the input field didn't get cleared yet val suggestionsAdapter = - SearchSuggestionsAdapter(response, autoTextView, this@SearchFragment) - binding.historyRecycler.adapter = suggestionsAdapter - } - } - if (!isFetchingSearch) run() - } - - fun fetchSearch(query: String) { - runOnUiThread { - binding.historyRecycler.visibility = GONE - } - lifecycleScope.launchWhenCreated { - isFetchingSearch = true - hideKeyboard() - val response = try { - RetrofitInstance.api.getSearchResults(query, apiSearchFilter) - } catch (e: IOException) { - println(e) - Log.e(TAG, "IOException, you might not have internet connection $e") - return@launchWhenCreated - } catch (e: HttpException) { - Log.e(TAG, "HttpException, unexpected response") - return@launchWhenCreated - } - nextPage = response.nextpage - if (response.items!!.isNotEmpty()) { - runOnUiThread { - binding.searchRecycler.visibility = VISIBLE - searchAdapter = SearchAdapter(response.items, childFragmentManager) - binding.searchRecycler.adapter = searchAdapter - } - } - addToHistory(query) - isLoading = false - isFetchingSearch = false - } - } - - private fun fetchNextSearchItems(query: String) { - lifecycleScope.launchWhenCreated { - if (!isLoading) { - isLoading = true - val response = try { - RetrofitInstance.api.getSearchResultsNextPage( - query, - apiSearchFilter, - nextPage!! + SearchSuggestionsAdapter( + response, + (activity as MainActivity).searchView ) - } catch (e: IOException) { - println(e) - Log.e(TAG, "IOException, you might not have internet connection") - return@launchWhenCreated - } catch (e: HttpException) { - Log.e(TAG, "HttpException, unexpected response," + e.response()) - return@launchWhenCreated + runOnUiThread { + if (viewModel.searchQuery.value != "") { + binding.suggestionsRecycler.adapter = suggestionsAdapter + } } - nextPage = response.nextpage - searchAdapter?.updateItems(response.items!!) - isLoading = false } } - } - - private fun Fragment?.runOnUiThread(action: () -> Unit) { - this ?: return - if (!isAdded) return // Fragment not attached to an Activity - activity?.runOnUiThread(action) - } - - override fun onResume() { - super.onResume() - requireActivity().window.setSoftInputMode(SOFT_INPUT_STATE_ALWAYS_HIDDEN) - } - - override fun onStop() { - super.onStop() - hideKeyboard() + run() } private fun showHistory() { - binding.searchRecycler.visibility = GONE - val historyList = PreferenceHelper.getHistory(requireContext()) + val historyList = PreferenceHelper.getSearchHistory() if (historyList.isNotEmpty()) { - binding.historyRecycler.adapter = + binding.suggestionsRecycler.adapter = SearchHistoryAdapter( - requireContext(), historyList, - binding.autoCompleteTextView, - this + (activity as MainActivity).searchView ) - binding.historyRecycler.visibility = VISIBLE - } - } - - private fun addToHistory(query: String) { - val searchHistoryEnabled = - PreferenceHelper.getBoolean(requireContext(), "search_history_toggle", true) - if (searchHistoryEnabled) { - var historyList = PreferenceHelper.getHistory(requireContext()) - - if ((historyList.isNotEmpty() && historyList.contains(query)) || query == "") { - return - } else { - historyList = historyList + query - } - - if (historyList.size > 10) { - historyList = historyList.takeLast(10) - } - - PreferenceHelper.saveHistory(requireContext(), historyList) + } else { + binding.suggestionsRecycler.visibility = View.GONE + binding.historyEmpty.visibility = View.VISIBLE } } } diff --git a/app/src/main/java/com/github/libretube/fragments/SearchResultFragment.kt b/app/src/main/java/com/github/libretube/fragments/SearchResultFragment.kt new file mode 100644 index 000000000..76ca8506f --- /dev/null +++ b/app/src/main/java/com/github/libretube/fragments/SearchResultFragment.kt @@ -0,0 +1,138 @@ +package com.github.libretube.fragments + +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager +import com.github.libretube.R +import com.github.libretube.adapters.SearchAdapter +import com.github.libretube.databinding.FragmentSearchResultBinding +import com.github.libretube.extensions.BaseFragment +import com.github.libretube.preferences.PreferenceHelper +import com.github.libretube.preferences.PreferenceKeys +import com.github.libretube.util.RetrofitInstance +import com.github.libretube.util.hideKeyboard +import retrofit2.HttpException +import java.io.IOException + +class SearchResultFragment : BaseFragment() { + private val TAG = "SearchResultFragment" + private lateinit var binding: FragmentSearchResultBinding + + private var nextPage: String? = null + private var query: String = "" + + private lateinit var searchAdapter: SearchAdapter + private var apiSearchFilter: String = "all" + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + query = arguments?.getString("query").toString() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragmentSearchResultBinding.inflate(layoutInflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + // add the query to the history + addToHistory(query) + + // filter options + binding.filterChipGroup.setOnCheckedStateChangeListener { _, _ -> + apiSearchFilter = when ( + binding.filterChipGroup.checkedChipId + ) { + R.id.chip_all -> "all" + R.id.chip_videos -> "videos" + R.id.chip_channels -> "channels" + R.id.chip_playlists -> "playlists" + R.id.chip_music_songs -> "music_songs" + R.id.chip_music_videos -> "music_videos" + R.id.chip_music_albums -> "music_albums" + R.id.chip_music_playlists -> "music_playlists" + else -> throw IllegalArgumentException("Filter out of range") + } + fetchSearch() + } + + fetchSearch() + + binding.searchRecycler.viewTreeObserver + .addOnScrollChangedListener { + if (!binding.searchRecycler.canScrollVertically(1)) { + if (nextPage != null) fetchNextSearchItems() + } + } + } + + private fun fetchSearch() { + lifecycleScope.launchWhenCreated { + view?.let { context?.hideKeyboard(it) } + val response = try { + RetrofitInstance.api.getSearchResults(query, apiSearchFilter) + } catch (e: IOException) { + println(e) + Log.e(TAG, "IOException, you might not have internet connection $e") + return@launchWhenCreated + } catch (e: HttpException) { + Log.e(TAG, "HttpException, unexpected response") + return@launchWhenCreated + } + runOnUiThread { + if (response.items?.isNotEmpty() == true) { + binding.searchRecycler.layoutManager = LinearLayoutManager(requireContext()) + searchAdapter = SearchAdapter(response.items, childFragmentManager) + binding.searchRecycler.adapter = searchAdapter + } else { + binding.searchContainer.visibility = View.GONE + binding.noSearchResult.visibility = View.VISIBLE + } + } + nextPage = response.nextpage + } + } + + private fun fetchNextSearchItems() { + lifecycleScope.launchWhenCreated { + val response = try { + RetrofitInstance.api.getSearchResultsNextPage( + query, + apiSearchFilter, + nextPage!! + ) + } catch (e: IOException) { + println(e) + Log.e(TAG, "IOException, you might not have internet connection") + return@launchWhenCreated + } catch (e: HttpException) { + Log.e(TAG, "HttpException, unexpected response," + e.response()) + return@launchWhenCreated + } + nextPage = response.nextpage!! + kotlin.runCatching { + if (response.items?.isNotEmpty() == true) { + searchAdapter.updateItems(response.items.toMutableList()) + } + } + } + } + + private fun addToHistory(query: String) { + val searchHistoryEnabled = + PreferenceHelper.getBoolean(PreferenceKeys.SEARCH_HISTORY_TOGGLE, true) + if (searchHistoryEnabled && query != "") { + PreferenceHelper.saveToSearchHistory(query) + } + } +} diff --git a/app/src/main/java/com/github/libretube/fragments/SubscriptionsFragment.kt b/app/src/main/java/com/github/libretube/fragments/SubscriptionsFragment.kt index 6e40f18cb..ded749700 100644 --- a/app/src/main/java/com/github/libretube/fragments/SubscriptionsFragment.kt +++ b/app/src/main/java/com/github/libretube/fragments/SubscriptionsFragment.kt @@ -5,30 +5,35 @@ import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.ProgressBar import android.widget.Toast import androidx.core.view.isVisible -import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView import com.github.libretube.R -import com.github.libretube.adapters.SubscriptionAdapter import com.github.libretube.adapters.SubscriptionChannelAdapter +import com.github.libretube.adapters.TrendingAdapter import com.github.libretube.databinding.FragmentSubscriptionsBinding +import com.github.libretube.extensions.BaseFragment +import com.github.libretube.obj.StreamItem import com.github.libretube.preferences.PreferenceHelper +import com.github.libretube.preferences.PreferenceKeys import com.github.libretube.util.RetrofitInstance +import com.github.libretube.util.SubscriptionHelper +import com.github.libretube.util.toID +import com.google.android.material.dialog.MaterialAlertDialogBuilder import retrofit2.HttpException import java.io.IOException -class SubscriptionsFragment : Fragment() { +class SubscriptionsFragment : BaseFragment() { val TAG = "SubFragment" private lateinit var binding: FragmentSubscriptionsBinding lateinit var token: String private var isLoaded = false - private var subscriptionAdapter: SubscriptionAdapter? = null + private var subscriptionAdapter: TrendingAdapter? = null + private var feed: List = listOf() + private var sortOrder = "most_recent" override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -47,69 +52,83 @@ class SubscriptionsFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - token = PreferenceHelper.getToken(requireContext()) + token = PreferenceHelper.getToken() - if (token != "") { - binding.loginOrRegister.visibility = View.GONE - binding.subRefresh.isEnabled = true + binding.subRefresh.isEnabled = true - binding.subProgress.visibility = View.VISIBLE + binding.subProgress.visibility = View.VISIBLE - val grid = PreferenceHelper.getString( - requireContext(), - "grid", - resources.getInteger(R.integer.grid_items).toString() - )!! - binding.subFeed.layoutManager = GridLayoutManager(view.context, grid.toInt()) - fetchFeed(binding.subFeed, binding.subProgress) + val grid = PreferenceHelper.getString( + PreferenceKeys.GRID_COLUMNS, + resources.getInteger(R.integer.grid_items).toString() + ) + binding.subFeed.layoutManager = GridLayoutManager(view.context, grid.toInt()) + fetchFeed() - binding.subRefresh.setOnRefreshListener { - fetchChannels(binding.subChannels) - fetchFeed(binding.subFeed, binding.subProgress) - } - - binding.toggleSubs.visibility = View.VISIBLE - var loadedSubbedChannels = false - - binding.toggleSubs.setOnClickListener { - binding.toggle.animate().rotationBy(180F).setDuration(100).start() - if (!binding.subChannels.isVisible) { - if (!loadedSubbedChannels) { - binding.subChannels.layoutManager = LinearLayoutManager(context) - fetchChannels(binding.subChannels) - loadedSubbedChannels = true - } - binding.subChannels.visibility = View.VISIBLE - binding.subFeed.visibility = View.GONE - } else { - binding.subChannels.visibility = View.GONE - binding.subFeed.visibility = View.VISIBLE - } - } - - binding.scrollviewSub.viewTreeObserver - .addOnScrollChangedListener { - if (binding.scrollviewSub.getChildAt(0).bottom - == (binding.scrollviewSub.height + binding.scrollviewSub.scrollY) - ) { - // scroll view is at bottom - if (isLoaded) { - binding.subRefresh.isRefreshing = true - subscriptionAdapter?.updateItems() - binding.subRefresh.isRefreshing = false - } - } - } - } else { - binding.subRefresh.isEnabled = false + binding.subRefresh.setOnRefreshListener { + fetchChannels() + fetchFeed() } + + binding.sortTV.setOnClickListener { + showSortDialog() + } + + binding.toggleSubs.visibility = View.VISIBLE + var loadedSubbedChannels = false + + binding.toggleSubs.setOnClickListener { + if (!binding.subChannelsContainer.isVisible) { + if (!loadedSubbedChannels) { + binding.subChannels.layoutManager = LinearLayoutManager(context) + fetchChannels() + loadedSubbedChannels = true + } + binding.subChannelsContainer.visibility = View.VISIBLE + binding.subFeedContainer.visibility = View.GONE + } else { + binding.subChannelsContainer.visibility = View.GONE + binding.subFeedContainer.visibility = View.VISIBLE + } + } + + binding.scrollviewSub.viewTreeObserver + .addOnScrollChangedListener { + if (binding.scrollviewSub.getChildAt(0).bottom + == (binding.scrollviewSub.height + binding.scrollviewSub.scrollY) + ) { + // scroll view is at bottom + if (isLoaded) { + binding.subRefresh.isRefreshing = true + subscriptionAdapter?.updateItems() + binding.subRefresh.isRefreshing = false + } + } + } } - private fun fetchFeed(feedRecView: RecyclerView, progressBar: ProgressBar) { + private fun showSortDialog() { + val sortOptions = resources.getStringArray(R.array.sortOptions) + val sortOptionValues = resources.getStringArray(R.array.sortOptionsValues) + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.sort) + .setItems(sortOptions) { _, index -> + binding.sortTV.text = sortOptions[index] + sortOrder = sortOptionValues[index] + showFeed() + } + .setNegativeButton(R.string.cancel, null) + .show() + } + + private fun fetchFeed() { fun run() { lifecycleScope.launchWhenCreated { - val response = try { - RetrofitInstance.authApi.getFeed(token) + feed = try { + if (token != "") RetrofitInstance.authApi.getFeed(token) + else RetrofitInstance.authApi.getUnauthenticatedFeed( + SubscriptionHelper.getFormattedLocalSubscriptions() + ) } catch (e: IOException) { Log.e(TAG, e.toString()) Log.e(TAG, "IOException, you might not have internet connection") @@ -120,35 +139,46 @@ class SubscriptionsFragment : Fragment() { } finally { binding.subRefresh.isRefreshing = false } - if (response.isNotEmpty()) { - subscriptionAdapter = SubscriptionAdapter(response, childFragmentManager) - feedRecView.adapter = subscriptionAdapter - subscriptionAdapter?.updateItems() + if (feed.isNotEmpty()) { + // save the last recent video to the prefs for the notification worker + PreferenceHelper.setLatestVideoId(feed[0].url.toID()) + // show the feed + showFeed() } else { runOnUiThread { - with(binding.boogh) { - visibility = View.VISIBLE - setImageResource(R.drawable.ic_list) - } - with(binding.textLike) { - visibility = View.VISIBLE - text = getString(R.string.emptyList) - } - binding.loginOrRegister.visibility = View.VISIBLE + binding.emptyFeed.visibility = View.VISIBLE } } - progressBar.visibility = View.GONE + binding.subProgress.visibility = View.GONE isLoaded = true } } run() } - private fun fetchChannels(channelRecView: RecyclerView) { + private fun showFeed() { + // sort the feed + val sortedFeed = when (sortOrder) { + "most_recent" -> feed + "least_recent" -> feed.reversed() + "most_views" -> feed.sortedBy { it.views }.reversed() + "least_views" -> feed.sortedBy { it.views } + "channel_name_az" -> feed.sortedBy { it.uploaderName } + "channel_name_za" -> feed.sortedBy { it.uploaderName }.reversed() + else -> feed + } + subscriptionAdapter = TrendingAdapter(sortedFeed, childFragmentManager, false) + binding.subFeed.adapter = subscriptionAdapter + } + + private fun fetchChannels() { fun run() { lifecycleScope.launchWhenCreated { val response = try { - RetrofitInstance.authApi.subscriptions(token) + if (token != "") RetrofitInstance.authApi.subscriptions(token) + else RetrofitInstance.authApi.unauthenticatedSubscriptions( + SubscriptionHelper.getFormattedLocalSubscriptions() + ) } catch (e: IOException) { Log.e(TAG, e.toString()) Log.e(TAG, "IOException, you might not have internet connection") @@ -160,7 +190,8 @@ class SubscriptionsFragment : Fragment() { binding.subRefresh.isRefreshing = false } if (response.isNotEmpty()) { - channelRecView.adapter = SubscriptionChannelAdapter(response.toMutableList()) + binding.subChannels.adapter = + SubscriptionChannelAdapter(response.toMutableList()) } else { Toast.makeText(context, R.string.subscribeIsEmpty, Toast.LENGTH_SHORT).show() } @@ -168,10 +199,4 @@ class SubscriptionsFragment : Fragment() { } run() } - - private fun Fragment?.runOnUiThread(action: () -> Unit) { - this ?: return - if (!isAdded) return // Fragment not attached to an Activity - activity?.runOnUiThread(action) - } } diff --git a/app/src/main/java/com/github/libretube/fragments/WatchHistoryFragment.kt b/app/src/main/java/com/github/libretube/fragments/WatchHistoryFragment.kt index dfee74fae..2aa4ad123 100644 --- a/app/src/main/java/com/github/libretube/fragments/WatchHistoryFragment.kt +++ b/app/src/main/java/com/github/libretube/fragments/WatchHistoryFragment.kt @@ -4,13 +4,15 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView import com.github.libretube.adapters.WatchHistoryAdapter import com.github.libretube.databinding.FragmentWatchHistoryBinding +import com.github.libretube.extensions.BaseFragment import com.github.libretube.preferences.PreferenceHelper -class WatchHistoryFragment : Fragment() { +class WatchHistoryFragment : BaseFragment() { private val TAG = "WatchHistoryFragment" private lateinit var binding: FragmentWatchHistoryBinding @@ -26,20 +28,58 @@ class WatchHistoryFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - val watchHistory = PreferenceHelper.getWatchHistory(requireContext()) - val watchHistoryAdapter = WatchHistoryAdapter(watchHistory, childFragmentManager) - binding.watchHistoryRecView.adapter = watchHistoryAdapter + val watchHistory = PreferenceHelper.getWatchHistory() - binding.clearHistory.setOnClickListener { - PreferenceHelper.removePreference(requireContext(), "watch_history") - watchHistoryAdapter.clear() + if (watchHistory.isEmpty()) return + + // reversed order + binding.watchHistoryRecView.layoutManager = LinearLayoutManager(requireContext()).apply { + reverseLayout = true + stackFromEnd = true } - // reverse order - val linearLayoutManager = LinearLayoutManager(view.context) - linearLayoutManager.reverseLayout = true - linearLayoutManager.stackFromEnd = true + val watchHistoryAdapter = WatchHistoryAdapter( + watchHistory, + childFragmentManager + ) - binding.watchHistoryRecView.layoutManager = linearLayoutManager + val itemTouchCallback = object : ItemTouchHelper.SimpleCallback( + 0, + ItemTouchHelper.LEFT + ) { + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder + ): Boolean { + return false + } + + override fun onSwiped( + viewHolder: RecyclerView.ViewHolder, + direction: Int + ) { + val position = viewHolder.absoluteAdapterPosition + watchHistoryAdapter.removeFromWatchHistory(position) + } + } + + val itemTouchHelper = ItemTouchHelper(itemTouchCallback) + itemTouchHelper.attachToRecyclerView(binding.watchHistoryRecView) + + // observe changes + watchHistoryAdapter.registerAdapterDataObserver(object : + RecyclerView.AdapterDataObserver() { + override fun onChanged() { + if (watchHistoryAdapter.itemCount == 0) { + binding.watchHistoryRecView.visibility = View.GONE + binding.historyEmpty.visibility = View.VISIBLE + } + } + }) + + binding.watchHistoryRecView.adapter = watchHistoryAdapter + binding.historyEmpty.visibility = View.GONE + binding.watchHistoryRecView.visibility = View.VISIBLE } } diff --git a/app/src/main/java/com/github/libretube/interfaces/DoubleTapInterface.kt b/app/src/main/java/com/github/libretube/interfaces/DoubleTapInterface.kt new file mode 100644 index 000000000..ba8bcd8f0 --- /dev/null +++ b/app/src/main/java/com/github/libretube/interfaces/DoubleTapInterface.kt @@ -0,0 +1,5 @@ +package com.github.libretube.interfaces + +interface DoubleTapInterface { + fun onEvent(x: Float) +} diff --git a/app/src/main/java/com/github/libretube/interfaces/PlayerOptionsInterface.kt b/app/src/main/java/com/github/libretube/interfaces/PlayerOptionsInterface.kt new file mode 100644 index 000000000..1f7bfbcb1 --- /dev/null +++ b/app/src/main/java/com/github/libretube/interfaces/PlayerOptionsInterface.kt @@ -0,0 +1,16 @@ +package com.github.libretube.interfaces + +interface PlayerOptionsInterface { + + fun onAutoplayClicked() + + fun onCaptionClicked() + + fun onQualityClicked() + + fun onPlaybackSpeedClicked() + + fun onAspectRatioClicked() + + fun onRepeatModeClicked() +} diff --git a/app/src/main/java/com/github/libretube/models/SearchViewModel.kt b/app/src/main/java/com/github/libretube/models/SearchViewModel.kt new file mode 100644 index 000000000..6a6690eb8 --- /dev/null +++ b/app/src/main/java/com/github/libretube/models/SearchViewModel.kt @@ -0,0 +1,12 @@ +package com.github.libretube.models + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel + +class SearchViewModel : ViewModel() { + var searchQuery = MutableLiveData() + + fun setQuery(query: String?) { + this.searchQuery.value = query + } +} diff --git a/app/src/main/java/com/github/libretube/obj/ChapterSegment.kt b/app/src/main/java/com/github/libretube/obj/ChapterSegment.kt index eb47218b3..38120cc81 100644 --- a/app/src/main/java/com/github/libretube/obj/ChapterSegment.kt +++ b/app/src/main/java/com/github/libretube/obj/ChapterSegment.kt @@ -4,9 +4,7 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties @JsonIgnoreProperties(ignoreUnknown = true) data class ChapterSegment( - var title: String?, - var image: String?, - var start: Long? -) { - constructor() : this("", "", -1) -} + var title: String? = null, + var image: String? = null, + var start: Long? = null +) diff --git a/app/src/main/java/com/github/libretube/obj/Comment.kt b/app/src/main/java/com/github/libretube/obj/Comment.kt index 40eb6689e..5266c0d47 100644 --- a/app/src/main/java/com/github/libretube/obj/Comment.kt +++ b/app/src/main/java/com/github/libretube/obj/Comment.kt @@ -4,17 +4,15 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties @JsonIgnoreProperties(ignoreUnknown = true) data class Comment( - val author: String?, - val commentId: String?, - val commentText: String?, - val commentedTime: String?, - val commentorUrl: String?, - val repliesPage: String?, - val hearted: Boolean?, - val likeCount: Int?, - val pinned: Boolean?, - val thumbnail: String?, - val verified: Boolean? -) { - constructor() : this("", "", "", "", "", "", null, 0, null, "", null) -} + val author: String? = null, + val commentId: String? = null, + val commentText: String? = null, + val commentedTime: String? = null, + val commentorUrl: String? = null, + val repliesPage: String? = null, + val hearted: Boolean? = null, + val likeCount: Int? = null, + val pinned: Boolean? = null, + val thumbnail: String? = null, + val verified: Boolean? = null +) diff --git a/app/src/main/java/com/github/libretube/obj/CommentsPage.kt b/app/src/main/java/com/github/libretube/obj/CommentsPage.kt index 4ba48f667..1244cc4a9 100644 --- a/app/src/main/java/com/github/libretube/obj/CommentsPage.kt +++ b/app/src/main/java/com/github/libretube/obj/CommentsPage.kt @@ -7,6 +7,4 @@ data class CommentsPage( val comments: MutableList = arrayListOf(), val disabled: Boolean? = null, val nextpage: String? = "" -) { - constructor() : this(arrayListOf(), null, "") -} +) diff --git a/app/src/main/java/com/github/libretube/obj/NewPipeSubscription.kt b/app/src/main/java/com/github/libretube/obj/NewPipeSubscription.kt new file mode 100644 index 000000000..1db358271 --- /dev/null +++ b/app/src/main/java/com/github/libretube/obj/NewPipeSubscription.kt @@ -0,0 +1,7 @@ +package com.github.libretube.obj + +data class NewPipeSubscription( + val name: String? = null, + val service_id: Int? = null, + val url: String? = null +) diff --git a/app/src/main/java/com/github/libretube/obj/NewPipeSubscriptions.kt b/app/src/main/java/com/github/libretube/obj/NewPipeSubscriptions.kt new file mode 100644 index 000000000..9c19551d2 --- /dev/null +++ b/app/src/main/java/com/github/libretube/obj/NewPipeSubscriptions.kt @@ -0,0 +1,7 @@ +package com.github.libretube.obj + +data class NewPipeSubscriptions( + val app_version: String = "", + val app_version_int: Int = 0, + val subscriptions: List? = null +) diff --git a/app/src/main/java/com/github/libretube/obj/PipedStream.kt b/app/src/main/java/com/github/libretube/obj/PipedStream.kt index 843698737..59109a210 100644 --- a/app/src/main/java/com/github/libretube/obj/PipedStream.kt +++ b/app/src/main/java/com/github/libretube/obj/PipedStream.kt @@ -4,20 +4,18 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties @JsonIgnoreProperties(ignoreUnknown = true) data class PipedStream( - var url: String?, - var format: String?, - var quality: String?, - var mimeType: String?, - var codec: String?, - var videoOnly: Boolean?, - var bitrate: Int?, - var initStart: Int?, - var initEnd: Int?, - var indexStart: Int?, - var indexEnd: Int?, - var width: Int?, - var height: Int?, - var fps: Int? -) { - constructor() : this("", "", "", "", "", null, -1, -1, -1, -1, -1, -1, -1, -1) -} + var url: String? = null, + var format: String? = null, + var quality: String? = null, + var mimeType: String? = null, + var codec: String? = null, + var videoOnly: Boolean? = null, + var bitrate: Int? = null, + var initStart: Int? = null, + var initEnd: Int? = null, + var indexStart: Int? = null, + var indexEnd: Int? = null, + var width: Int? = null, + var height: Int? = null, + var fps: Int? = null +) diff --git a/app/src/main/java/com/github/libretube/obj/SearchItem.kt b/app/src/main/java/com/github/libretube/obj/SearchItem.kt index 43006239b..526ecc071 100644 --- a/app/src/main/java/com/github/libretube/obj/SearchItem.kt +++ b/app/src/main/java/com/github/libretube/obj/SearchItem.kt @@ -4,25 +4,23 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties @JsonIgnoreProperties(ignoreUnknown = true) data class SearchItem( - var url: String?, - var thumbnail: String?, - var uploaderName: String?, - var uploaded: Long?, - var shortDescription: String?, + var url: String? = null, + var thumbnail: String? = null, + var uploaderName: String? = null, + var uploaded: Long? = null, + var shortDescription: String? = null, // Video only attributes - var title: String?, - var uploaderUrl: String?, - var uploaderAvatar: String?, - var uploadedDate: String?, - var duration: Long?, - var views: Long?, - var uploaderVerified: Boolean?, + var title: String? = null, + var uploaderUrl: String? = null, + var uploaderAvatar: String? = null, + var uploadedDate: String? = null, + var duration: Long? = null, + var views: Long? = null, + var uploaderVerified: Boolean? = null, // Channel and Playlist attributes var name: String? = null, var description: String? = null, var subscribers: Long? = -1, var videos: Long? = -1, var verified: Boolean? = null -) { - constructor() : this("", "", "", 0, "", "", "", "", "", 0, 0, null) -} +) diff --git a/app/src/main/java/com/github/libretube/obj/Segment.kt b/app/src/main/java/com/github/libretube/obj/Segment.kt index 85ab9d344..b4eff6aec 100644 --- a/app/src/main/java/com/github/libretube/obj/Segment.kt +++ b/app/src/main/java/com/github/libretube/obj/Segment.kt @@ -4,9 +4,7 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties @JsonIgnoreProperties(ignoreUnknown = true) data class Segment( - val actionType: String?, - val category: String?, - val segment: List? -) { - constructor() : this("", "", arrayListOf()) -} + val actionType: String? = null, + val category: String? = null, + val segment: List? = arrayListOf() +) diff --git a/app/src/main/java/com/github/libretube/obj/Segments.kt b/app/src/main/java/com/github/libretube/obj/Segments.kt index af3628cdc..e7c1b1297 100644 --- a/app/src/main/java/com/github/libretube/obj/Segments.kt +++ b/app/src/main/java/com/github/libretube/obj/Segments.kt @@ -5,6 +5,4 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties @JsonIgnoreProperties(ignoreUnknown = true) data class Segments( val segments: MutableList = arrayListOf() -) { - constructor() : this(arrayListOf()) -} +) diff --git a/app/src/main/java/com/github/libretube/obj/SponsorBlockPrefs.kt b/app/src/main/java/com/github/libretube/obj/SponsorBlockPrefs.kt deleted file mode 100644 index 73118d5ca..000000000 --- a/app/src/main/java/com/github/libretube/obj/SponsorBlockPrefs.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.github.libretube.obj - -class SponsorBlockPrefs( - var sponsorBlockEnabled: Boolean = false, - var sponsorNotificationsEnabled: Boolean = false, - var sponsorsEnabled: Boolean = false, - var selfPromoEnabled: Boolean = false, - var interactionEnabled: Boolean = false, - var introEnabled: Boolean = false, - var outroEnabled: Boolean = false, - var fillerEnabled: Boolean = false, - var musicOffTopicEnabled: Boolean = false, - var previewEnabled: Boolean = false -) diff --git a/app/src/main/java/com/github/libretube/obj/StreamItem.kt b/app/src/main/java/com/github/libretube/obj/StreamItem.kt index f22e4d256..b8eb2ee9e 100644 --- a/app/src/main/java/com/github/libretube/obj/StreamItem.kt +++ b/app/src/main/java/com/github/libretube/obj/StreamItem.kt @@ -4,18 +4,16 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties @JsonIgnoreProperties(ignoreUnknown = true) data class StreamItem( - var url: String?, - var title: String?, - var thumbnail: String?, - var uploaderName: String?, - var uploaderUrl: String?, - var uploaderAvatar: String?, - var uploadedDate: String?, - var duration: Long?, - var views: Long?, - var uploaderVerified: Boolean?, - var uploaded: Long?, - var shortDescription: String? -) { - constructor() : this("", "", "", "", "", "", "", 0, 0, null, 0, "") -} + var url: String? = null, + var title: String? = null, + var thumbnail: String? = null, + var uploaderName: String? = null, + var uploaderUrl: String? = null, + var uploaderAvatar: String? = null, + var uploadedDate: String? = null, + var duration: Long? = null, + var views: Long? = null, + var uploaderVerified: Boolean? = null, + var uploaded: Long? = null, + var shortDescription: String? = null +) diff --git a/app/src/main/java/com/github/libretube/obj/Streams.kt b/app/src/main/java/com/github/libretube/obj/Streams.kt index 34613b73e..b497859ac 100644 --- a/app/src/main/java/com/github/libretube/obj/Streams.kt +++ b/app/src/main/java/com/github/libretube/obj/Streams.kt @@ -15,7 +15,7 @@ data class Streams( val dash: String?, val lbryId: String?, val uploaderVerified: Boolean?, - val duration: Int?, + val duration: Long?, val views: Long?, val likes: Long?, val dislikes: Long?, diff --git a/app/src/main/java/com/github/libretube/obj/Subtitle.kt b/app/src/main/java/com/github/libretube/obj/Subtitle.kt index fa80f697a..1bcdc6957 100644 --- a/app/src/main/java/com/github/libretube/obj/Subtitle.kt +++ b/app/src/main/java/com/github/libretube/obj/Subtitle.kt @@ -4,11 +4,9 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties @JsonIgnoreProperties(ignoreUnknown = true) data class Subtitle( - val url: String?, - val mimeType: String?, - val name: String?, - val code: String?, - val autoGenerated: Boolean? -) { - constructor() : this("", "", "", "", null) -} + val url: String? = null, + val mimeType: String? = null, + val name: String? = null, + val code: String? = null, + val autoGenerated: Boolean? = null +) diff --git a/app/src/main/java/com/github/libretube/obj/UpdateInfo.kt b/app/src/main/java/com/github/libretube/obj/UpdateInfo.kt deleted file mode 100644 index 91808912b..000000000 --- a/app/src/main/java/com/github/libretube/obj/UpdateInfo.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.github.libretube.obj - -// data class for the update info, required to return the data -data class UpdateInfo( - val updateUrl: String, - val tagName: String -) diff --git a/app/src/main/java/com/github/libretube/obj/WatchHistoryItem.kt b/app/src/main/java/com/github/libretube/obj/WatchHistoryItem.kt index b8eab4111..f8b436d5f 100644 --- a/app/src/main/java/com/github/libretube/obj/WatchHistoryItem.kt +++ b/app/src/main/java/com/github/libretube/obj/WatchHistoryItem.kt @@ -1,12 +1,12 @@ package com.github.libretube.obj data class WatchHistoryItem( - val videoId: String?, - val title: String?, - val uploadDate: String?, - val uploader: String?, - val uploaderUrl: String?, - val uploaderAvatar: String?, - val thumbnailUrl: String?, - val duration: Int? + val videoId: String? = null, + val title: String? = null, + val uploadDate: String? = null, + val uploader: String? = null, + val uploaderUrl: String? = null, + val uploaderAvatar: String? = null, + val thumbnailUrl: String? = null, + val duration: Long? = null ) diff --git a/app/src/main/java/com/github/libretube/obj/WatchPosition.kt b/app/src/main/java/com/github/libretube/obj/WatchPosition.kt index b7b000310..e98b4e812 100644 --- a/app/src/main/java/com/github/libretube/obj/WatchPosition.kt +++ b/app/src/main/java/com/github/libretube/obj/WatchPosition.kt @@ -1,6 +1,6 @@ package com.github.libretube.obj data class WatchPosition( - val videoId: String, - val position: Long + val videoId: String = "", + val position: Long = 0L ) diff --git a/app/src/main/java/com/github/libretube/preferences/AdvancedSettings.kt b/app/src/main/java/com/github/libretube/preferences/AdvancedSettings.kt index 750fe5f9a..ceeb221bf 100644 --- a/app/src/main/java/com/github/libretube/preferences/AdvancedSettings.kt +++ b/app/src/main/java/com/github/libretube/preferences/AdvancedSettings.kt @@ -2,13 +2,12 @@ package com.github.libretube.preferences import android.os.Bundle import androidx.preference.Preference -import androidx.preference.PreferenceFragmentCompat import com.github.libretube.R import com.github.libretube.activities.SettingsActivity -import com.github.libretube.dialogs.RequireRestartDialog +import com.github.libretube.views.MaterialPreferenceFragment import com.google.android.material.dialog.MaterialAlertDialogBuilder -class AdvancedSettings : PreferenceFragmentCompat() { +class AdvancedSettings : MaterialPreferenceFragment() { val TAG = "AdvancedSettings" override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { @@ -17,22 +16,7 @@ class AdvancedSettings : PreferenceFragmentCompat() { val settingsActivity = activity as SettingsActivity settingsActivity.changeTopBarText(getString(R.string.advanced)) - // clear search history - val clearHistory = findPreference("clear_history") - clearHistory?.setOnPreferenceClickListener { - PreferenceHelper.removePreference(requireContext(), "search_history") - true - } - - // clear watch history and positions - val clearWatchHistory = findPreference("clear_watch_history") - clearWatchHistory?.setOnPreferenceClickListener { - PreferenceHelper.removePreference(requireContext(), "watch_history") - PreferenceHelper.removePreference(requireContext(), "watch_positions") - true - } - - val resetSettings = findPreference("reset_settings") + val resetSettings = findPreference(PreferenceKeys.RESET_SETTINGS) resetSettings?.setOnPreferenceClickListener { showResetDialog() true @@ -41,19 +25,18 @@ class AdvancedSettings : PreferenceFragmentCompat() { private fun showResetDialog() { MaterialAlertDialogBuilder(requireContext()) - .setPositiveButton(R.string.reset) { _, _ -> - // clear default preferences - PreferenceHelper.clearPreferences(requireContext()) - - // clear login token - PreferenceHelper.setToken(requireContext(), "") - - val restartDialog = RequireRestartDialog() - restartDialog.show(childFragmentManager, "RequireRestartDialog") - } - .setNegativeButton(getString(R.string.cancel)) { _, _ -> } .setTitle(R.string.reset) .setMessage(R.string.reset_message) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.reset) { _, _ -> + // clear default preferences + PreferenceHelper.clearPreferences() + + // clear login token + PreferenceHelper.setToken("") + + activity?.recreate() + } .show() } } diff --git a/app/src/main/java/com/github/libretube/preferences/AppearanceSettings.kt b/app/src/main/java/com/github/libretube/preferences/AppearanceSettings.kt index 5a6dcc5e9..7c5ce8f5d 100644 --- a/app/src/main/java/com/github/libretube/preferences/AppearanceSettings.kt +++ b/app/src/main/java/com/github/libretube/preferences/AppearanceSettings.kt @@ -1,16 +1,21 @@ package com.github.libretube.preferences +import android.content.ActivityNotFoundException +import android.content.Intent import android.os.Bundle +import android.provider.Settings +import android.widget.Toast import androidx.preference.ListPreference -import androidx.preference.PreferenceFragmentCompat +import androidx.preference.Preference import androidx.preference.SwitchPreferenceCompat import com.github.libretube.R import com.github.libretube.activities.SettingsActivity import com.github.libretube.dialogs.RequireRestartDialog import com.github.libretube.util.ThemeHelper +import com.github.libretube.views.MaterialPreferenceFragment import com.google.android.material.color.DynamicColors -class AppearanceSettings : PreferenceFragmentCompat() { +class AppearanceSettings : MaterialPreferenceFragment() { private val TAG = "AppearanceSettings" override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { setPreferencesFromResource(R.xml.appearance_settings, rootKey) @@ -18,52 +23,59 @@ class AppearanceSettings : PreferenceFragmentCompat() { val settingsActivity = activity as SettingsActivity settingsActivity.changeTopBarText(getString(R.string.appearance)) - val themeToggle = findPreference("theme_toggle") + val themeToggle = findPreference(PreferenceKeys.THEME_MODE) themeToggle?.setOnPreferenceChangeListener { _, _ -> val restartDialog = RequireRestartDialog() - restartDialog.show(childFragmentManager, "RequireRestartDialog") + restartDialog.show(childFragmentManager, RequireRestartDialog::class.java.name) true } - val pureTheme = findPreference("pure_theme") + val pureTheme = findPreference(PreferenceKeys.PURE_THEME) pureTheme?.setOnPreferenceChangeListener { _, _ -> val restartDialog = RequireRestartDialog() - restartDialog.show(childFragmentManager, "RequireRestartDialog") + restartDialog.show(childFragmentManager, RequireRestartDialog::class.java.name) true } - val accentColor = findPreference("accent_color") + val accentColor = findPreference(PreferenceKeys.ACCENT_COLOR) updateAccentColorValues(accentColor!!) accentColor.setOnPreferenceChangeListener { _, _ -> val restartDialog = RequireRestartDialog() - restartDialog.show(childFragmentManager, "RequireRestartDialog") + restartDialog.show(childFragmentManager, RequireRestartDialog::class.java.name) true } - val iconChange = findPreference("icon_change") + val iconChange = findPreference(PreferenceKeys.APP_ICON) iconChange?.setOnPreferenceChangeListener { _, newValue -> ThemeHelper.changeIcon(requireContext(), newValue.toString()) true } - val gridColumns = findPreference("grid") - gridColumns?.setOnPreferenceChangeListener { _, _ -> - val restartDialog = RequireRestartDialog() - restartDialog.show(childFragmentManager, "RequireRestartDialog") - true - } - - val hideTrending = findPreference("hide_trending_page") - hideTrending?.setOnPreferenceChangeListener { _, _ -> - val restartDialog = RequireRestartDialog() - restartDialog.show(childFragmentManager, "RequireRestartDialog") - true - } - - val labelVisibilityMode = findPreference("label_visibility") + val labelVisibilityMode = findPreference(PreferenceKeys.LABEL_VISIBILITY) labelVisibilityMode?.setOnPreferenceChangeListener { _, _ -> val restartDialog = RequireRestartDialog() - restartDialog.show(childFragmentManager, "RequireRestartDialog") + restartDialog.show(childFragmentManager, RequireRestartDialog::class.java.name) + true + } + + val systemCaptionStyle = + findPreference(PreferenceKeys.SYSTEM_CAPTION_STYLE) + val captionSettings = findPreference(PreferenceKeys.CAPTION_SETTINGS) + + captionSettings?.isVisible = + PreferenceHelper.getBoolean(PreferenceKeys.SYSTEM_CAPTION_STYLE, true) + systemCaptionStyle?.setOnPreferenceChangeListener { _, newValue -> + captionSettings?.isVisible = newValue as Boolean + true + } + + captionSettings?.setOnPreferenceClickListener { + try { + val captionSettingsIntent = Intent(Settings.ACTION_CAPTIONING_SETTINGS) + startActivity(captionSettingsIntent) + } catch (e: ActivityNotFoundException) { + Toast.makeText(activity, R.string.error, Toast.LENGTH_SHORT).show() + } true } } diff --git a/app/src/main/java/com/github/libretube/preferences/GeneralSettings.kt b/app/src/main/java/com/github/libretube/preferences/GeneralSettings.kt new file mode 100644 index 000000000..fe88d4504 --- /dev/null +++ b/app/src/main/java/com/github/libretube/preferences/GeneralSettings.kt @@ -0,0 +1,48 @@ +package com.github.libretube.preferences + +import android.os.Bundle +import androidx.preference.ListPreference +import androidx.preference.SwitchPreferenceCompat +import com.github.libretube.R +import com.github.libretube.activities.SettingsActivity +import com.github.libretube.dialogs.RequireRestartDialog +import com.github.libretube.views.MaterialPreferenceFragment + +class GeneralSettings : MaterialPreferenceFragment() { + val TAG = "SettingsFragment" + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + setPreferencesFromResource(R.xml.general_settings, rootKey) + + val settingsActivity = activity as SettingsActivity + settingsActivity.changeTopBarText(getString(R.string.general)) + + val language = findPreference("language") + language?.setOnPreferenceChangeListener { _, _ -> + val restartDialog = RequireRestartDialog() + restartDialog.show(childFragmentManager, RequireRestartDialog::class.java.name) + true + } + + val autoRotation = findPreference(PreferenceKeys.AUTO_ROTATION) + autoRotation?.setOnPreferenceChangeListener { _, _ -> + val restartDialog = RequireRestartDialog() + restartDialog.show(childFragmentManager, RequireRestartDialog::class.java.name) + true + } + + val hideTrending = findPreference(PreferenceKeys.HIDE_TRENDING_PAGE) + hideTrending?.setOnPreferenceChangeListener { _, _ -> + val restartDialog = RequireRestartDialog() + restartDialog.show(childFragmentManager, RequireRestartDialog::class.java.name) + true + } + + val breakReminder = findPreference(PreferenceKeys.BREAK_REMINDER) + breakReminder?.setOnPreferenceChangeListener { _, _ -> + val restartDialog = RequireRestartDialog() + restartDialog.show(childFragmentManager, RequireRestartDialog::class.java.name) + true + } + } +} diff --git a/app/src/main/java/com/github/libretube/preferences/HistorySettings.kt b/app/src/main/java/com/github/libretube/preferences/HistorySettings.kt new file mode 100644 index 000000000..12c57e1e5 --- /dev/null +++ b/app/src/main/java/com/github/libretube/preferences/HistorySettings.kt @@ -0,0 +1,51 @@ +package com.github.libretube.preferences + +import android.os.Bundle +import androidx.preference.Preference +import com.github.libretube.R +import com.github.libretube.activities.SettingsActivity +import com.github.libretube.views.MaterialPreferenceFragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder + +class HistorySettings : MaterialPreferenceFragment() { + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + setPreferencesFromResource(R.xml.history_settings, rootKey) + + val settingsActivity = activity as SettingsActivity + settingsActivity.changeTopBarText(getString(R.string.history)) + + // clear search history + val clearHistory = findPreference(PreferenceKeys.CLEAR_SEARCH_HISTORY) + clearHistory?.setOnPreferenceClickListener { + showClearDialog(R.string.clear_history, "search_history") + true + } + + // clear watch history and positions + val clearWatchHistory = findPreference(PreferenceKeys.CLEAR_WATCH_HISTORY) + clearWatchHistory?.setOnPreferenceClickListener { + showClearDialog(R.string.clear_history, "watch_history") + true + } + + // clear watch positions + val clearWatchPositions = findPreference(PreferenceKeys.CLEAR_WATCH_POSITIONS) + clearWatchPositions?.setOnPreferenceClickListener { + showClearDialog(R.string.reset_watch_positions, "watch_positions") + true + } + } + + private fun showClearDialog(title: Int, preferenceKey: String) { + MaterialAlertDialogBuilder(requireContext()) + .setTitle(title) + .setMessage(R.string.irreversible) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.okay) { _, _ -> + // clear the selected preference preferences + PreferenceHelper.removePreference(preferenceKey) + } + .show() + } +} diff --git a/app/src/main/java/com/github/libretube/preferences/InstanceSettings.kt b/app/src/main/java/com/github/libretube/preferences/InstanceSettings.kt index 084d661af..12c2a9381 100644 --- a/app/src/main/java/com/github/libretube/preferences/InstanceSettings.kt +++ b/app/src/main/java/com/github/libretube/preferences/InstanceSettings.kt @@ -1,23 +1,15 @@ package com.github.libretube.preferences -import android.Manifest -import android.content.ContentResolver import android.content.Intent -import android.content.pm.PackageManager import android.net.Uri -import android.os.Build import android.os.Bundle -import android.text.TextUtils -import android.util.Log import android.widget.Toast +import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts -import androidx.core.app.ActivityCompat -import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import androidx.preference.ListPreference import androidx.preference.Preference -import androidx.preference.PreferenceFragmentCompat import androidx.preference.SwitchPreferenceCompat import com.github.libretube.R import com.github.libretube.activities.SettingsActivity @@ -25,87 +17,33 @@ import com.github.libretube.dialogs.CustomInstanceDialog import com.github.libretube.dialogs.DeleteAccountDialog import com.github.libretube.dialogs.LoginDialog import com.github.libretube.dialogs.LogoutDialog -import com.github.libretube.dialogs.RequireRestartDialog +import com.github.libretube.util.ImportHelper +import com.github.libretube.util.PermissionHelper import com.github.libretube.util.RetrofitInstance -import org.json.JSONObject -import org.json.JSONTokener -import retrofit2.HttpException -import java.io.IOException -import java.io.InputStream -import java.util.zip.ZipEntry -import java.util.zip.ZipInputStream +import com.github.libretube.views.MaterialPreferenceFragment -class InstanceSettings : PreferenceFragmentCompat() { +class InstanceSettings : MaterialPreferenceFragment() { val TAG = "InstanceSettings" + /** + * result listeners for importing and exporting subscriptions + */ + private lateinit var getContent: ActivityResultLauncher + private lateinit var createFile: ActivityResultLauncher + override fun onCreate(savedInstanceState: Bundle?) { - MainSettings.getContent = - registerForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? -> - if (uri != null) { - try { - // Open a specific media item using ParcelFileDescriptor. - val resolver: ContentResolver = - requireActivity() - .contentResolver - - // "rw" for read-and-write; - // "rwt" for truncating or overwriting existing file contents. - // val readOnlyMode = "r" - // uri - I have got from onActivityResult - val type = resolver.getType(uri) - - var inputStream: InputStream? = resolver.openInputStream(uri) - val channels = ArrayList() - if (type == "application/json") { - val json = inputStream?.bufferedReader()?.readLines()?.get(0) - val jsonObject = JSONTokener(json).nextValue() as JSONObject - Log.e(TAG, jsonObject.getJSONArray("subscriptions").toString()) - for ( - i in 0 until jsonObject.getJSONArray("subscriptions") - .length() - ) { - var url = - jsonObject.getJSONArray("subscriptions").getJSONObject(i) - .getString("url") - url = url.replace("https://www.youtube.com/channel/", "") - Log.e(TAG, url) - channels.add(url) - } - } else { - if (type == "application/zip") { - val zis = ZipInputStream(inputStream) - var entry: ZipEntry? = zis.nextEntry - while (entry != null) { - if (entry.name.endsWith(".csv")) { - inputStream = zis - break - } - entry = zis.nextEntry - } - } - - inputStream?.bufferedReader()?.readLines()?.forEach { - if (it.isNotBlank()) { - val channelId = it.substringBefore(",") - if (channelId.length == 24) { - channels.add(channelId) - } - } - } - } - inputStream?.close() - - subscribe(channels) - } catch (e: Exception) { - Log.e(TAG, e.toString()) - Toast.makeText( - context, - R.string.error, - Toast.LENGTH_SHORT - ).show() - } - } + getContent = + registerForActivityResult( + ActivityResultContracts.GetContent() + ) { uri: Uri? -> + ImportHelper(requireActivity()).importSubscriptions(uri) } + createFile = registerForActivityResult( + ActivityResultContracts.CreateDocument() + ) { uri: Uri? -> + ImportHelper(requireActivity()).exportSubscriptions(uri) + } + super.onCreate(savedInstanceState) } @@ -115,25 +53,24 @@ class InstanceSettings : PreferenceFragmentCompat() { val settingsActivity = activity as SettingsActivity settingsActivity.changeTopBarText(getString(R.string.instance)) - val instance = findPreference("selectInstance") + val instance = findPreference(PreferenceKeys.FETCH_INSTANCE) // fetchInstance() initCustomInstances(instance!!) instance.setOnPreferenceChangeListener { _, newValue -> - val restartDialog = RequireRestartDialog() - restartDialog.show(childFragmentManager, "RequireRestartDialog") RetrofitInstance.url = newValue.toString() - if (!PreferenceHelper.getBoolean(requireContext(), "auth_instance_toggle", false)) { + if (!PreferenceHelper.getBoolean(PreferenceKeys.AUTH_INSTANCE_TOGGLE, false)) { RetrofitInstance.authUrl = newValue.toString() logout() } RetrofitInstance.lazyMgr.reset() + activity?.recreate() true } - val authInstance = findPreference("selectAuthInstance") + val authInstance = findPreference(PreferenceKeys.AUTH_INSTANCE) initCustomInstances(authInstance!!) // hide auth instance if option deselected - if (!PreferenceHelper.getBoolean(requireContext(), "auth_instance_toggle", false)) { + if (!PreferenceHelper.getBoolean(PreferenceKeys.AUTH_INSTANCE_TOGGLE, false)) { authInstance.isVisible = false } authInstance.setOnPreferenceChangeListener { _, newValue -> @@ -141,226 +78,132 @@ class InstanceSettings : PreferenceFragmentCompat() { RetrofitInstance.authUrl = newValue.toString() RetrofitInstance.lazyMgr.reset() logout() - val restartDialog = RequireRestartDialog() - restartDialog.show(childFragmentManager, "RequireRestartDialog") + activity?.recreate() true } - val authInstanceToggle = findPreference("auth_instance_toggle") + val authInstanceToggle = + findPreference(PreferenceKeys.AUTH_INSTANCE_TOGGLE) authInstanceToggle?.setOnPreferenceChangeListener { _, newValue -> authInstance.isVisible = newValue == true logout() // either use new auth url or the normal api url if auth instance disabled RetrofitInstance.authUrl = if (newValue == false) RetrofitInstance.url else authInstance.value - val restartDialog = RequireRestartDialog() - restartDialog.show(childFragmentManager, "RequireRestartDialog") + RetrofitInstance.lazyMgr.reset() + activity?.recreate() true } - val customInstance = findPreference("customInstance") + val customInstance = findPreference(PreferenceKeys.CUSTOM_INSTANCE) customInstance?.setOnPreferenceClickListener { val newFragment = CustomInstanceDialog() - newFragment.show(childFragmentManager, "CustomInstanceDialog") + newFragment.show(childFragmentManager, CustomInstanceDialog::class.java.name) true } - val clearCustomInstances = findPreference("clearCustomInstances") + val clearCustomInstances = findPreference(PreferenceKeys.CLEAR_CUSTOM_INSTANCES) clearCustomInstances?.setOnPreferenceClickListener { - PreferenceHelper.removePreference(requireContext(), "customInstances") + PreferenceHelper.removePreference("customInstances") val intent = Intent(context, SettingsActivity::class.java) startActivity(intent) true } - val login = findPreference("login_register") - val token = PreferenceHelper.getToken(requireContext()) + val login = findPreference(PreferenceKeys.LOGIN_REGISTER) + val token = PreferenceHelper.getToken() if (token != "") login?.setTitle(R.string.logout) login?.setOnPreferenceClickListener { if (token == "") { val newFragment = LoginDialog() - newFragment.show(childFragmentManager, "Login") + newFragment.show(childFragmentManager, LoginDialog::class.java.name) } else { val newFragment = LogoutDialog() - newFragment.show(childFragmentManager, "Logout") + newFragment.show(childFragmentManager, LogoutDialog::class.java.name) } true } - val deleteAccount = findPreference("delete_account") + val deleteAccount = findPreference(PreferenceKeys.DELETE_ACCOUNT) deleteAccount?.setOnPreferenceClickListener { - val token = PreferenceHelper.getToken(requireContext()) + val token = PreferenceHelper.getToken() if (token != "") { val newFragment = DeleteAccountDialog() - newFragment.show(childFragmentManager, "DeleteAccountDialog") + newFragment.show(childFragmentManager, DeleteAccountDialog::class.java.name) } else { Toast.makeText(context, R.string.login_first, Toast.LENGTH_SHORT).show() } true } - val importFromYt = findPreference("import_from_yt") - importFromYt?.setOnPreferenceClickListener { - importSubscriptions() + val importSubscriptions = findPreference(PreferenceKeys.IMPORT_SUBS) + importSubscriptions?.setOnPreferenceClickListener { + // check StorageAccess + val accessGranted = + PermissionHelper.isStoragePermissionGranted(requireActivity()) + // import subscriptions + if (accessGranted) getContent.launch("*/*") + // request permissions if not granted + else PermissionHelper.requestReadWrite(requireActivity()) + true + } + + val exportSubscriptions = findPreference(PreferenceKeys.EXPORT_SUBS) + exportSubscriptions?.setOnPreferenceClickListener { + createFile.launch("subscriptions.json") true } } private fun initCustomInstances(instancePref: ListPreference) { - val customInstances = PreferenceHelper.getCustomInstances(requireContext()) - - var instanceNames = resources.getStringArray(R.array.instances) - var instanceValues = resources.getStringArray(R.array.instancesValue) - customInstances.forEach { instance -> - instanceNames += instance.name - instanceValues += instance.apiUrl - } - - // add custom instances to the list preference - instancePref.entries = instanceNames - instancePref.entryValues = instanceValues - instancePref.summaryProvider = - Preference.SummaryProvider { preference -> - val text = preference.entry - if (TextUtils.isEmpty(text)) { - "kavin.rocks (Official)" - } else { - text - } - } - } - - private fun logout() { - PreferenceHelper.setToken(requireContext(), "") - Toast.makeText(context, getString(R.string.loggedout), Toast.LENGTH_SHORT).show() - } - - private fun fetchInstance() { lifecycleScope.launchWhenCreated { + val customInstances = PreferenceHelper.getCustomInstances() + + val instanceNames = arrayListOf() + val instanceValues = arrayListOf() + + // fetch official public instances + val response = try { RetrofitInstance.api.getInstances("https://instances.tokhmi.xyz/") - } catch (e: IOException) { - println(e) - Log.e("settings", "IOException, you might not have internet connection") - return@launchWhenCreated - } catch (e: HttpException) { - Log.e("settings", "HttpException, unexpected response $e") - return@launchWhenCreated } catch (e: Exception) { - Log.e("settings", e.toString()) - return@launchWhenCreated - } - val listEntries: MutableList = ArrayList() - val listEntryValues: MutableList = ArrayList() - for (item in response) { - listEntries.add(item.name!!) - listEntryValues.add(item.api_url!!) + e.printStackTrace() + emptyList() } - // add custom instances to the list + response.forEach { + if (it.name != null && it.api_url != null) { + instanceNames += it.name!! + instanceValues += it.api_url!! + } + } + + customInstances.forEach { instance -> + instanceNames += instance.name + instanceValues += instance.apiUrl + } - val entries = listEntries.toTypedArray() - val entryValues = listEntryValues.toTypedArray() runOnUiThread { - val instance = findPreference("selectInstance") - instance?.entries = entries - instance?.entryValues = entryValues - instance?.summaryProvider = + // add custom instances to the list preference + instancePref.entries = instanceNames.toTypedArray() + instancePref.entryValues = instanceValues.toTypedArray() + instancePref.summaryProvider = Preference.SummaryProvider { preference -> - val text = preference.entry - if (TextUtils.isEmpty(text)) { - "kavin.rocks (Official)" - } else { - text - } + preference.entry } } } } + private fun logout() { + PreferenceHelper.setToken("") + Toast.makeText(context, getString(R.string.loggedout), Toast.LENGTH_SHORT).show() + } + private fun Fragment?.runOnUiThread(action: () -> Unit) { this ?: return if (!isAdded) return // Fragment not attached to an Activity activity?.runOnUiThread(action) } - - private fun importSubscriptions() { - val token = PreferenceHelper.getToken(requireContext()) - // check StorageAccess - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - Log.d("myz", "" + Build.VERSION.SDK_INT) - if (ContextCompat.checkSelfPermission( - this.requireContext(), - Manifest.permission.READ_EXTERNAL_STORAGE - ) - != PackageManager.PERMISSION_GRANTED - ) { - ActivityCompat.requestPermissions( - this.requireActivity(), - arrayOf( - Manifest.permission.READ_EXTERNAL_STORAGE, - Manifest.permission.MANAGE_EXTERNAL_STORAGE - ), - 1 - ) // permission request code is just an int - } else if (token != "") { - MainSettings.getContent.launch("*/*") - } else { - Toast.makeText(context, R.string.login_first, Toast.LENGTH_SHORT).show() - } - } else { - if (ActivityCompat.checkSelfPermission( - requireContext(), - Manifest.permission.READ_EXTERNAL_STORAGE - ) != PackageManager.PERMISSION_GRANTED || - ActivityCompat.checkSelfPermission( - requireContext(), - Manifest.permission.WRITE_EXTERNAL_STORAGE - ) != PackageManager.PERMISSION_GRANTED - ) { - ActivityCompat.requestPermissions( - this.requireActivity(), - arrayOf( - Manifest.permission.READ_EXTERNAL_STORAGE, - Manifest.permission.WRITE_EXTERNAL_STORAGE - ), - 1 - ) - } else if (token != "") { - MainSettings.getContent.launch("*/*") - } else { - Toast.makeText(context, R.string.login_first, Toast.LENGTH_SHORT).show() - } - } - } - - private fun subscribe(channels: List) { - fun run() { - lifecycleScope.launchWhenCreated { - val response = try { - val token = PreferenceHelper.getToken(requireContext()) - RetrofitInstance.authApi.importSubscriptions( - false, - token, - channels - ) - } catch (e: IOException) { - Log.e(TAG, "IOException, you might not have internet connection") - return@launchWhenCreated - } catch (e: HttpException) { - Log.e(TAG, "HttpException, unexpected response$e") - return@launchWhenCreated - } - if (response.message == "ok") { - Toast.makeText( - context, - R.string.importsuccess, - Toast.LENGTH_SHORT - ).show() - } - } - } - run() - } } diff --git a/app/src/main/java/com/github/libretube/preferences/MainSettings.kt b/app/src/main/java/com/github/libretube/preferences/MainSettings.kt index f131e0ecd..dbad3d703 100644 --- a/app/src/main/java/com/github/libretube/preferences/MainSettings.kt +++ b/app/src/main/java/com/github/libretube/preferences/MainSettings.kt @@ -1,38 +1,29 @@ package com.github.libretube.preferences import android.os.Bundle -import androidx.activity.result.ActivityResultLauncher import androidx.fragment.app.Fragment -import androidx.preference.ListPreference import androidx.preference.Preference -import androidx.preference.PreferenceFragmentCompat import com.github.libretube.BuildConfig -import com.github.libretube.Globals import com.github.libretube.R -import com.github.libretube.dialogs.RequireRestartDialog -import com.github.libretube.util.ThemeHelper -import com.github.libretube.util.checkUpdate +import com.github.libretube.activities.SettingsActivity +import com.github.libretube.dialogs.UpdateDialog +import com.github.libretube.update.UpdateChecker +import com.github.libretube.views.MaterialPreferenceFragment +import com.google.android.material.snackbar.Snackbar +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch -class MainSettings : PreferenceFragmentCompat() { +class MainSettings : MaterialPreferenceFragment() { val TAG = "SettingsFragment" - companion object { - lateinit var getContent: ActivityResultLauncher - } - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { setPreferencesFromResource(R.xml.settings, rootKey) - val region = findPreference("region") - region?.setOnPreferenceChangeListener { _, _ -> - val restartDialog = RequireRestartDialog() - restartDialog.show(childFragmentManager, "RequireRestartDialog") - true - } - - val language = findPreference("language") - language?.setOnPreferenceChangeListener { _, _ -> - ThemeHelper.restartMainActivity(requireContext()) + val general = findPreference("general") + general?.setOnPreferenceClickListener { + val newFragment = GeneralSettings() + navigateToSettingsFragment(newFragment) true } @@ -64,6 +55,20 @@ class MainSettings : PreferenceFragmentCompat() { true } + val history = findPreference("history") + history?.setOnPreferenceClickListener { + val newFragment = HistorySettings() + navigateToSettingsFragment(newFragment) + true + } + + val notifications = findPreference("notifications") + notifications?.setOnPreferenceClickListener { + val newFragment = NotificationSettings() + navigateToSettingsFragment(newFragment) + true + } + val advanced = findPreference("advanced") advanced?.setOnPreferenceClickListener { val newFragment = AdvancedSettings() @@ -72,29 +77,48 @@ class MainSettings : PreferenceFragmentCompat() { } val update = findPreference("update") - update?.title = getString(R.string.version, BuildConfig.VERSION_NAME) + + // set the version of the update preference + val versionString = if (BuildConfig.DEBUG) "${BuildConfig.VERSION_NAME} Debug" + else getString(R.string.version, BuildConfig.VERSION_NAME) + update?.title = versionString + + // checking for update: yes -> dialog, no -> snackBar update?.setOnPreferenceClickListener { - checkUpdate(childFragmentManager) - true - } - - val about = findPreference("about") - about?.setOnPreferenceClickListener { - val newFragment = AboutFragment() - navigateToSettingsFragment(newFragment) - true - } - - val community = findPreference("community") - community?.setOnPreferenceClickListener { - val newFragment = CommunityFragment() - navigateToSettingsFragment(newFragment) + CoroutineScope(Dispatchers.IO).launch { + // check for update + val updateInfo = UpdateChecker.getLatestReleaseInfo() + if (updateInfo?.name == null) { + // request failed + val settingsActivity = activity as SettingsActivity + val snackBar = Snackbar + .make( + settingsActivity.binding.root, + R.string.unknown_error, + Snackbar.LENGTH_SHORT + ) + snackBar.show() + } else if (BuildConfig.VERSION_NAME != updateInfo.name) { + // show the UpdateAvailableDialog if there's an update available + val updateAvailableDialog = UpdateDialog(updateInfo) + updateAvailableDialog.show(childFragmentManager, UpdateDialog::class.java.name) + } else { + // otherwise show the no update available snackBar + val settingsActivity = activity as SettingsActivity + val snackBar = Snackbar + .make( + settingsActivity.binding.root, + R.string.app_uptodate, + Snackbar.LENGTH_SHORT + ) + snackBar.show() + } + } true } } private fun navigateToSettingsFragment(newFragment: Fragment) { - Globals.isCurrentViewMainSettings = false parentFragmentManager.beginTransaction() .replace(R.id.settings, newFragment) .commitNow() diff --git a/app/src/main/java/com/github/libretube/preferences/NotificationSettings.kt b/app/src/main/java/com/github/libretube/preferences/NotificationSettings.kt new file mode 100644 index 000000000..bea9048aa --- /dev/null +++ b/app/src/main/java/com/github/libretube/preferences/NotificationSettings.kt @@ -0,0 +1,42 @@ +package com.github.libretube.preferences + +import android.os.Bundle +import androidx.preference.ListPreference +import androidx.preference.SwitchPreferenceCompat +import androidx.work.ExistingPeriodicWorkPolicy +import com.github.libretube.R +import com.github.libretube.activities.SettingsActivity +import com.github.libretube.util.NotificationHelper +import com.github.libretube.views.MaterialPreferenceFragment + +class NotificationSettings : MaterialPreferenceFragment() { + val TAG = "SettingsFragment" + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + setPreferencesFromResource(R.xml.notification_settings, rootKey) + + val settingsActivity = activity as SettingsActivity + settingsActivity.changeTopBarText(getString(R.string.notifications)) + + val notificationsEnabled = + findPreference(PreferenceKeys.NOTIFICATION_ENABLED) + notificationsEnabled?.setOnPreferenceChangeListener { _, _ -> + updateNotificationPrefs() + true + } + + val checkingFrequency = findPreference(PreferenceKeys.CHECKING_FREQUENCY) + checkingFrequency?.setOnPreferenceChangeListener { _, _ -> + updateNotificationPrefs() + true + } + } + + private fun updateNotificationPrefs() { + // replace the previous queued work request + NotificationHelper.enqueueWork( + requireContext(), + ExistingPeriodicWorkPolicy.REPLACE + ) + } +} diff --git a/app/src/main/java/com/github/libretube/preferences/PlayerSettings.kt b/app/src/main/java/com/github/libretube/preferences/PlayerSettings.kt index b3428f4f7..ae1df72ec 100644 --- a/app/src/main/java/com/github/libretube/preferences/PlayerSettings.kt +++ b/app/src/main/java/com/github/libretube/preferences/PlayerSettings.kt @@ -2,12 +2,14 @@ package com.github.libretube.preferences import android.os.Bundle import androidx.preference.ListPreference -import androidx.preference.PreferenceFragmentCompat +import androidx.preference.Preference import androidx.preference.SwitchPreferenceCompat import com.github.libretube.R import com.github.libretube.activities.SettingsActivity +import com.github.libretube.views.MaterialPreferenceFragment +import java.util.* -class PlayerSettings : PreferenceFragmentCompat() { +class PlayerSettings : MaterialPreferenceFragment() { val TAG = "PlayerSettings" override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { @@ -16,13 +18,14 @@ class PlayerSettings : PreferenceFragmentCompat() { val settingsActivity = activity as SettingsActivity settingsActivity.changeTopBarText(getString(R.string.audio_video)) - val playerOrientation = findPreference("fullscreen_orientation") - val autoRotateToFullscreen = findPreference("auto_fullscreen") + val playerOrientation = + findPreference(PreferenceKeys.FULLSCREEN_ORIENTATION) + val autoRotateToFullscreen = + findPreference(PreferenceKeys.AUTO_FULLSCREEN) // only show the player orientation option if auto fullscreen is disabled playerOrientation?.isEnabled != PreferenceHelper.getBoolean( - requireContext(), - "auto_fullscreen", + PreferenceKeys.AUTO_FULLSCREEN, false ) @@ -30,5 +33,26 @@ class PlayerSettings : PreferenceFragmentCompat() { playerOrientation?.isEnabled = newValue != true true } + + val defaultSubtitle = findPreference(PreferenceKeys.DEFAULT_SUBTITLE) + val locales: Array = Locale.getAvailableLocales() + val localeNames = ArrayList() + val localeCodes = ArrayList() + + localeNames.add(context?.getString(R.string.none)!!) + localeCodes.add("") + + locales.forEach { + if (!localeNames.contains(it.getDisplayLanguage())) { + localeNames.add(it.getDisplayLanguage()) + localeCodes.add(it.language) + } + } + defaultSubtitle?.entries = localeNames.toTypedArray() + defaultSubtitle?.entryValues = localeCodes.toTypedArray() + defaultSubtitle?.summaryProvider = + Preference.SummaryProvider { preference -> + preference.entry + } } } diff --git a/app/src/main/java/com/github/libretube/preferences/PreferenceHelper.kt b/app/src/main/java/com/github/libretube/preferences/PreferenceHelper.kt index 1e00a7fd2..e53f18863 100644 --- a/app/src/main/java/com/github/libretube/preferences/PreferenceHelper.kt +++ b/app/src/main/java/com/github/libretube/preferences/PreferenceHelper.kt @@ -3,176 +3,192 @@ package com.github.libretube.preferences import android.content.Context import android.content.SharedPreferences import androidx.preference.PreferenceManager +import com.fasterxml.jackson.core.type.TypeReference +import com.fasterxml.jackson.databind.ObjectMapper import com.github.libretube.obj.CustomInstance import com.github.libretube.obj.Streams import com.github.libretube.obj.WatchHistoryItem import com.github.libretube.obj.WatchPosition -import com.google.common.reflect.TypeToken -import com.google.gson.Gson -import java.lang.reflect.Type +import com.github.libretube.util.toID object PreferenceHelper { private val TAG = "PreferenceHelper" - fun setString(context: Context, key: String?, value: String?) { - val editor = getDefaultSharedPreferencesEditor(context) - editor.putString(key, value) - editor.apply() + private lateinit var prefContext: Context + private lateinit var settings: SharedPreferences + private lateinit var editor: SharedPreferences.Editor + private val mapper = ObjectMapper() + + /** + * set the context that is being used to access the shared preferences + */ + fun setContext(context: Context) { + prefContext = context + settings = getDefaultSharedPreferences(prefContext) + editor = settings.edit() } - fun setInt(context: Context, key: String?, value: Int) { - val editor = getDefaultSharedPreferencesEditor(context) - editor.putInt(key, value) - editor.apply() + fun getString(key: String?, defValue: String?): String { + return settings.getString(key, defValue)!! } - fun setLong(context: Context, key: String?, value: Long) { - val editor = getDefaultSharedPreferencesEditor(context) - editor.putLong(key, value) - editor.apply() - } - - fun setBoolean(context: Context, key: String?, value: Boolean) { - val editor = getDefaultSharedPreferencesEditor(context) - editor.putBoolean(key, value) - editor.apply() - } - - fun getString(context: Context, key: String?, defValue: String?): String? { - val settings: SharedPreferences = getDefaultSharedPreferences(context) - return settings.getString(key, defValue) - } - - fun getInt(context: Context, key: String?, defValue: Int): Int { - val settings: SharedPreferences = getDefaultSharedPreferences(context) - return settings.getInt(key, defValue) - } - - fun getLong(context: Context, key: String?, defValue: Long): Long { - val settings: SharedPreferences = getDefaultSharedPreferences(context) - return settings.getLong(key, defValue) - } - - fun getBoolean(context: Context, key: String?, defValue: Boolean): Boolean { - val settings: SharedPreferences = getDefaultSharedPreferences(context) + fun getBoolean(key: String?, defValue: Boolean): Boolean { return settings.getBoolean(key, defValue) } - fun clearPreferences(context: Context) { - val editor = getDefaultSharedPreferencesEditor(context) + fun clearPreferences() { editor.clear().apply() } - fun removePreference(context: Context, value: String?) { - val editor = getDefaultSharedPreferencesEditor(context) + fun removePreference(value: String?) { editor.remove(value).apply() } - fun getToken(context: Context): String { - val sharedPref = context.getSharedPreferences("token", Context.MODE_PRIVATE) + fun getToken(): String { + val sharedPref = prefContext.getSharedPreferences("token", Context.MODE_PRIVATE) return sharedPref?.getString("token", "")!! } - fun setToken(context: Context, newValue: String) { - val editor = context.getSharedPreferences("token", Context.MODE_PRIVATE).edit() + fun setToken(newValue: String) { + val editor = prefContext.getSharedPreferences("token", Context.MODE_PRIVATE).edit() editor.putString("token", newValue).apply() } - fun getUsername(context: Context): String { - val sharedPref = context.getSharedPreferences("username", Context.MODE_PRIVATE) + fun getUsername(): String { + val sharedPref = prefContext.getSharedPreferences("username", Context.MODE_PRIVATE) return sharedPref.getString("username", "")!! } - fun setUsername(context: Context, newValue: String) { - val editor = context.getSharedPreferences("username", Context.MODE_PRIVATE).edit() + fun setUsername(newValue: String) { + val editor = prefContext.getSharedPreferences("username", Context.MODE_PRIVATE).edit() editor.putString("username", newValue).apply() } - fun saveCustomInstance(context: Context, customInstance: CustomInstance) { - val editor = getDefaultSharedPreferencesEditor(context) - val gson = Gson() - - val customInstancesList = getCustomInstances(context) + fun saveCustomInstance(customInstance: CustomInstance) { + val customInstancesList = getCustomInstances() customInstancesList += customInstance - val json = gson.toJson(customInstancesList) + val json = mapper.writeValueAsString(customInstancesList) editor.putString("customInstances", json).apply() } - fun getCustomInstances(context: Context): ArrayList { - val settings = getDefaultSharedPreferences(context) - val gson = Gson() + fun getCustomInstances(): ArrayList { val json: String = settings.getString("customInstances", "")!! - val type: Type = object : TypeToken?>() {}.type + val type = mapper.typeFactory.constructCollectionType( + List::class.java, + CustomInstance::class.java + ) return try { - gson.fromJson(json, type) + mapper.readValue(json, type) } catch (e: Exception) { arrayListOf() } } - fun getHistory(context: Context): List { + fun getSearchHistory(): List { return try { - val settings = getDefaultSharedPreferences(context) - val set: Set = settings.getStringSet("search_history", HashSet())!! - set.toList() + val json = settings.getString("search_history", "")!! + val type = object : TypeReference>() {} + return mapper.readValue(json, type) } catch (e: Exception) { emptyList() } } - fun saveHistory(context: Context, historyList: List) { - val editor = getDefaultSharedPreferencesEditor(context) - val set: Set = HashSet(historyList) - editor.putStringSet("search_history", set).apply() + fun saveToSearchHistory(query: String) { + val historyList = getSearchHistory().toMutableList() + + if ((historyList.contains(query))) { + // remove from history list if already contained + historyList -= query + } + + // append new query to history + historyList.add(0, query) + + if (historyList.size > 10) { + historyList.removeAt(historyList.size - 1) + } + + updateSearchHistory(historyList) } - fun addToWatchHistory(context: Context, videoId: String, streams: Streams) { - val editor = getDefaultSharedPreferencesEditor(context) - val gson = Gson() + fun removeFromSearchHistory(query: String) { + val historyList = getSearchHistory().toMutableList() + historyList -= query + updateSearchHistory(historyList) + } + + private fun updateSearchHistory(historyList: List) { + val json = mapper.writeValueAsString(historyList) + editor.putString("search_history", json).apply() + } + + fun addToWatchHistory(videoId: String, streams: Streams) { + removeFromWatchHistory(videoId) val watchHistoryItem = WatchHistoryItem( videoId, streams.title, streams.uploadDate, streams.uploader, - streams.uploaderUrl?.replace("/channel/", ""), + streams.uploaderUrl.toID(), streams.uploaderAvatar, streams.thumbnailUrl, streams.duration ) - val watchHistory = getWatchHistory(context) + val watchHistory = getWatchHistory() + + watchHistory += watchHistoryItem + + // remove oldest item when the watch history is longer than the pref + val maxWatchHistorySize = getString(PreferenceKeys.WATCH_HISTORY_SIZE, "unlimited") + if (maxWatchHistorySize != "unlimited" && watchHistory.size > maxWatchHistorySize.toInt()) { + watchHistory.removeAt(0) + } + + val json = mapper.writeValueAsString(watchHistory) + editor.putString("watch_history", json).apply() + } + + fun removeFromWatchHistory(videoId: String) { + val watchHistory = getWatchHistory() - // delete entries that have the same videoId var indexToRemove: Int? = null watchHistory.forEachIndexed { index, item -> if (item.videoId == videoId) indexToRemove = index } - if (indexToRemove != null) watchHistory.removeAt(indexToRemove!!) - - watchHistory += watchHistoryItem - - val json = gson.toJson(watchHistory) - editor.putString("watch_history", json).apply() + if (indexToRemove == null) return + watchHistory.removeAt(indexToRemove!!) + val json = mapper.writeValueAsString(watchHistory) + editor.putString("watch_history", json).commit() } - fun getWatchHistory(context: Context): ArrayList { - val settings = getDefaultSharedPreferences(context) - val gson = Gson() + fun removeFromWatchHistory(position: Int) { + val watchHistory = getWatchHistory() + watchHistory.removeAt(position) + + val json = mapper.writeValueAsString(watchHistory) + editor.putString("watch_history", json).commit() + } + + fun getWatchHistory(): ArrayList { val json: String = settings.getString("watch_history", "")!! - val type: Type = object : TypeToken?>() {}.type + val type = mapper.typeFactory.constructCollectionType( + List::class.java, + WatchHistoryItem::class.java + ) + return try { - gson.fromJson(json, type) + mapper.readValue(json, type) } catch (e: Exception) { arrayListOf() } } - fun saveWatchPosition(context: Context, videoId: String, position: Long) { - val editor = getDefaultSharedPreferencesEditor(context) - - val watchPositions = getWatchPositions(context) + fun saveWatchPosition(videoId: String, position: Long) { + val watchPositions = getWatchPositions() val watchPositionItem = WatchPosition(videoId, position) var indexToRemove: Int? = null @@ -184,15 +200,12 @@ object PreferenceHelper { watchPositions += watchPositionItem - val gson = Gson() - val json = gson.toJson(watchPositions) + val json = mapper.writeValueAsString(watchPositions) editor.putString("watch_positions", json).commit() } - fun removeWatchPosition(context: Context, videoId: String) { - val editor = getDefaultSharedPreferencesEditor(context) - - val watchPositions = getWatchPositions(context) + fun removeWatchPosition(videoId: String) { + val watchPositions = getWatchPositions() var indexToRemove: Int? = null watchPositions.forEachIndexed { index, item -> @@ -201,28 +214,56 @@ object PreferenceHelper { if (indexToRemove != null) watchPositions.removeAt(indexToRemove!!) - val gson = Gson() - val json = gson.toJson(watchPositions) + val json = mapper.writeValueAsString(watchPositions) editor.putString("watch_positions", json).commit() } - fun getWatchPositions(context: Context): ArrayList { - val settings = getDefaultSharedPreferences(context) - val gson = Gson() + fun getWatchPositions(): ArrayList { val json: String = settings.getString("watch_positions", "")!! - val type: Type = object : TypeToken?>() {}.type + val type = mapper.typeFactory.constructCollectionType( + List::class.java, + WatchPosition::class.java + ) + return try { - gson.fromJson(json, type) + mapper.readValue(json, type) } catch (e: Exception) { arrayListOf() } } + fun setLatestVideoId(videoId: String) { + editor.putString(PreferenceKeys.LAST_STREAM_VIDEO_ID, videoId) + } + + fun getLatestVideoId(): String { + return getString(PreferenceKeys.LAST_STREAM_VIDEO_ID, "") + } + + fun saveErrorLog(log: String) { + editor.putString(PreferenceKeys.ERROR_LOG, log).commit() + } + + fun getErrorLog(): String { + return getString(PreferenceKeys.ERROR_LOG, "") + } + + fun getLocalSubscriptions(): List { + val json = settings.getString(PreferenceKeys.LOCAL_SUBSCRIPTIONS, "") + return try { + val type = object : TypeReference>() {} + mapper.readValue(json, type) + } catch (e: Exception) { + listOf() + } + } + + fun setLocalSubscriptions(channels: List) { + val json = mapper.writeValueAsString(channels) + editor.putString(PreferenceKeys.LOCAL_SUBSCRIPTIONS, json).commit() + } + private fun getDefaultSharedPreferences(context: Context): SharedPreferences { return PreferenceManager.getDefaultSharedPreferences(context) } - - private fun getDefaultSharedPreferencesEditor(context: Context): SharedPreferences.Editor { - return getDefaultSharedPreferences(context).edit() - } } diff --git a/app/src/main/java/com/github/libretube/preferences/PreferenceKeys.kt b/app/src/main/java/com/github/libretube/preferences/PreferenceKeys.kt new file mode 100644 index 000000000..9dd059dbe --- /dev/null +++ b/app/src/main/java/com/github/libretube/preferences/PreferenceKeys.kt @@ -0,0 +1,106 @@ +package com.github.libretube.preferences + +/** + * keys for the shared preferences + */ +object PreferenceKeys { + /** + * General + */ + const val LANGUAGE = "language" + const val REGION = "region" + const val AUTO_ROTATION = "auto_rotation" + const val BREAK_REMINDER = "break_reminder" + + /** + * Appearance + */ + const val THEME_MODE = "theme_toggle" + const val PURE_THEME = "pure_theme" + const val ACCENT_COLOR = "accent_color" + const val GRID_COLUMNS = "grid" + const val DEFAULT_TAB = "default_tab" + const val LABEL_VISIBILITY = "label_visibility" + const val HIDE_TRENDING_PAGE = "hide_trending_page" + const val APP_ICON = "icon_change" + + /** + * Instance + */ + const val FETCH_INSTANCE = "selectInstance" + const val AUTH_INSTANCE = "selectAuthInstance" + const val AUTH_INSTANCE_TOGGLE = "auth_instance_toggle" + const val CUSTOM_INSTANCE = "customInstance" + const val CLEAR_CUSTOM_INSTANCES = "clearCustomInstances" + const val LOGIN_REGISTER = "login_register" + const val DELETE_ACCOUNT = "delete_account" + const val IMPORT_SUBS = "import_from_yt" + const val EXPORT_SUBS = "export_subs" + + /** + * Player + */ + const val AUTO_FULLSCREEN = "auto_fullscreen" + const val AUTO_PLAY = "autoplay" + const val RELATED_STREAMS = "related_streams_toggle" + const val PLAYBACK_SPEED = "playback_speed" + const val FULLSCREEN_ORIENTATION = "fullscreen_orientation" + const val PAUSE_ON_SCREEN_OFF = "pause_screen_off" + const val WATCH_POSITION_TOGGLE = "watch_position_toggle" + const val WATCH_HISTORY_TOGGLE = "watch_history_toggle" + const val SEARCH_HISTORY_TOGGLE = "search_history_toggle" + const val SYSTEM_CAPTION_STYLE = "system_caption_style" + const val CAPTION_SETTINGS = "caption_settings" + const val SEEK_INCREMENT = "seek_increment" + const val PLAYER_VIDEO_FORMAT = "player_video_format" + const val DEFAULT_RESOLUTION = "default_res" + const val BUFFERING_GOAL = "buffering_goal" + const val PLAYER_AUDIO_FORMAT = "player_audio_format" + const val PLAYER_AUDIO_QUALITY = "player_audio_quality" + const val DEFAULT_SUBTITLE = "default_subtitle" + const val SKIP_BUTTONS = "skip_buttons" + + /** + * Background mode + */ + const val BACKGROUND_PLAYBACK_SPEED = "background_playback_speed" + + /** + * Download + */ + const val DOWNLOAD_LOCATION = "download_location" + const val DOWNLOAD_FOLDER = "download_folder" + + /** + * Notifications + */ + const val NOTIFICATION_ENABLED = "notification_toggle" + const val CHECKING_FREQUENCY = "checking_frequency" + const val REQUIRED_NETWORK = "required_network" + const val LAST_STREAM_VIDEO_ID = "last_stream_video_id" + + /** + * Advanced + */ + const val DATA_SAVER_MODE = "data_saver_mode" + const val RESET_SETTINGS = "reset_settings" + const val CLEAR_SEARCH_HISTORY = "clear_search_history" + const val CLEAR_WATCH_HISTORY = "clear_watch_history" + const val CLEAR_WATCH_POSITIONS = "clear_watch_positions" + const val SHARE_WITH_TIME_CODE = "share_with_time_code" + + /** + * History + */ + const val WATCH_HISTORY_SIZE = "watch_history_size" + + /** + * Error logs + */ + const val ERROR_LOG = "error_log" + + /** + * Data + */ + const val LOCAL_SUBSCRIPTIONS = "local_subscriptions" +} diff --git a/app/src/main/java/com/github/libretube/preferences/SponsorBlockSettings.kt b/app/src/main/java/com/github/libretube/preferences/SponsorBlockSettings.kt index 1feb8b1de..6654bc8ba 100644 --- a/app/src/main/java/com/github/libretube/preferences/SponsorBlockSettings.kt +++ b/app/src/main/java/com/github/libretube/preferences/SponsorBlockSettings.kt @@ -1,11 +1,11 @@ package com.github.libretube.preferences import android.os.Bundle -import androidx.preference.PreferenceFragmentCompat import com.github.libretube.R import com.github.libretube.activities.SettingsActivity +import com.github.libretube.views.MaterialPreferenceFragment -class SponsorBlockSettings : PreferenceFragmentCompat() { +class SponsorBlockSettings : MaterialPreferenceFragment() { private val TAG = "SponsorBlockSettings" override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { diff --git a/app/src/main/java/com/github/libretube/services/BackgroundMode.kt b/app/src/main/java/com/github/libretube/services/BackgroundMode.kt new file mode 100644 index 000000000..0ad0f53b8 --- /dev/null +++ b/app/src/main/java/com/github/libretube/services/BackgroundMode.kt @@ -0,0 +1,322 @@ +package com.github.libretube.services + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.content.Intent +import android.os.Build +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import android.widget.Toast +import com.fasterxml.jackson.databind.ObjectMapper +import com.github.libretube.BACKGROUND_CHANNEL_ID +import com.github.libretube.Globals +import com.github.libretube.PLAYER_NOTIFICATION_ID +import com.github.libretube.R +import com.github.libretube.obj.Segment +import com.github.libretube.obj.Segments +import com.github.libretube.obj.Streams +import com.github.libretube.preferences.PreferenceHelper +import com.github.libretube.preferences.PreferenceKeys +import com.github.libretube.util.AutoPlayHelper +import com.github.libretube.util.NowPlayingNotification +import com.github.libretube.util.PlayerHelper +import com.github.libretube.util.RetrofitInstance +import com.github.libretube.util.toID +import com.google.android.exoplayer2.C +import com.google.android.exoplayer2.ExoPlayer +import com.google.android.exoplayer2.MediaItem +import com.google.android.exoplayer2.Player +import com.google.android.exoplayer2.audio.AudioAttributes +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking + +/** + * Loads the selected videos audio in background mode with a notification area. + */ +class BackgroundMode : Service() { + /** + * VideoId of the video + */ + private lateinit var videoId: String + + /** + *PlaylistId for autoplay + */ + private var playlistId: String? = null + + /** + * The response that gets when called the Api. + */ + private var streams: Streams? = null + + /** + * The [ExoPlayer] player. Followed tutorial [here](https://developer.android.com/codelabs/exoplayer-intro) + */ + private var player: ExoPlayer? = null + private var playWhenReadyPlayer = true + + /** + * The [AudioAttributes] handle the audio focus of the [player] + */ + private lateinit var audioAttributes: AudioAttributes + + /** + * SponsorBlock Segment data + */ + private var segmentData: Segments? = null + + /** + * [Notification] for the player + */ + private lateinit var nowPlayingNotification: NowPlayingNotification + + /** + * The [videoId] of the next stream for autoplay + */ + private var nextStreamId: String? = null + + /** + * Helper for finding the next video in the playlist + */ + private lateinit var autoPlayHelper: AutoPlayHelper + + /** + * Autoplay Preference + */ + private val autoplay = PreferenceHelper.getBoolean(PreferenceKeys.AUTO_PLAY, true) + + /** + * Setting the required [Notification] for running as a foreground service + */ + override fun onCreate() { + super.onCreate() + if (Build.VERSION.SDK_INT >= 26) { + val channelId = BACKGROUND_CHANNEL_ID + val channel = NotificationChannel( + channelId, + "Background Service", + NotificationManager.IMPORTANCE_DEFAULT + ) + val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(channel) + val notification: Notification = Notification.Builder(this, channelId) + .setContentTitle(getString(R.string.app_name)) + .setContentText(getString(R.string.playingOnBackground)).build() + startForeground(PLAYER_NOTIFICATION_ID, notification) + } + } + + /** + * Initializes the [player] with the [MediaItem]. + */ + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + try { + // clear the playing queue + Globals.playingQueue.clear() + + // get the intent arguments + videoId = intent?.getStringExtra("videoId")!! + playlistId = intent.getStringExtra("playlistId") + val position = intent.getLongExtra("position", 0L) + + // initialize the playlist autoPlay Helper + if (playlistId != null) autoPlayHelper = AutoPlayHelper(playlistId!!) + + // play the audio in the background + playAudio(videoId, position) + } catch (e: Exception) { + stopForeground(true) + stopSelf() + } + return super.onStartCommand(intent, flags, startId) + } + + /** + * Gets the video data and prepares the [player]. + */ + private fun playAudio( + videoId: String, + seekToPosition: Long = 0 + ) { + // append the video to the playing queue + Globals.playingQueue += videoId + runBlocking { + val job = launch { + streams = RetrofitInstance.api.getStreams(videoId) + } + // Wait until the job is done, to load correctly later in the player + job.join() + + initializePlayer() + setMediaItem() + + // create the notification + if (!this@BackgroundMode::nowPlayingNotification.isInitialized) { + nowPlayingNotification = NowPlayingNotification(this@BackgroundMode, player!!) + } + nowPlayingNotification.updatePlayerNotification(streams!!) + + player?.apply { + playWhenReady = playWhenReadyPlayer + prepare() + } + + // seek to the previous position if available + if (seekToPosition != 0L) player?.seekTo(seekToPosition) + + // set the playback speed + val playbackSpeed = PreferenceHelper.getString( + PreferenceKeys.BACKGROUND_PLAYBACK_SPEED, + "1" + ).toFloat() + player?.setPlaybackSpeed(playbackSpeed) + + fetchSponsorBlockSegments() + + if (autoplay) setNextStream() + } + } + + /** + * create the player + */ + private fun initializePlayer() { + if (player != null) return + + audioAttributes = AudioAttributes.Builder() + .setUsage(C.USAGE_MEDIA) + .setContentType(C.CONTENT_TYPE_MUSIC) + .build() + player = ExoPlayer.Builder(this) + .setAudioAttributes(audioAttributes, true) + .build() + + /** + * Listens for changed playbackStates (e.g. pause, end) + * Plays the next video when the current one ended + */ + player!!.addListener(object : Player.Listener { + override fun onPlaybackStateChanged(@Player.State state: Int) { + when (state) { + Player.STATE_ENDED -> { + if (autoplay) playNextVideo() + } + Player.STATE_IDLE -> { + onDestroy() + } + } + } + }) + } + + /** + * set the videoId of the next stream for autoplay + */ + private fun setNextStream() { + if (streams!!.relatedStreams!!.isNotEmpty()) { + nextStreamId = streams?.relatedStreams!![0].url.toID() + } + + if (playlistId == null) return + if (!this::autoPlayHelper.isInitialized) autoPlayHelper = AutoPlayHelper(playlistId!!) + // search for the next videoId in the playlist + CoroutineScope(Dispatchers.IO).launch { + nextStreamId = autoPlayHelper.getNextVideoId(videoId, streams!!.relatedStreams!!) + } + } + + /** + * Plays the first related video to the current (used when the playback of the current video ended) + */ + private fun playNextVideo() { + if (nextStreamId == null || nextStreamId == videoId) return + val nextQueueVideo = autoPlayHelper.getNextPlayingQueueVideoId(videoId) + if (nextQueueVideo != null) nextStreamId = nextQueueVideo + + // play new video on background + this.videoId = nextStreamId!! + this.segmentData = null + playAudio(videoId) + } + + /** + * Sets the [MediaItem] with the [streams] into the [player] + */ + private fun setMediaItem() { + streams?.let { + val mediaItem = MediaItem.Builder().setUri(it.hls!!).build() + player?.setMediaItem(mediaItem) + } + } + + /** + * fetch the segments for SponsorBlock + */ + private fun fetchSponsorBlockSegments() { + CoroutineScope(Dispatchers.IO).launch { + kotlin.runCatching { + val categories = PlayerHelper.getSponsorBlockCategories() + if (categories.size > 0) { + segmentData = + RetrofitInstance.api.getSegments( + videoId, + ObjectMapper().writeValueAsString(categories) + ) + checkForSegments() + } + } + } + } + + /** + * check for SponsorBlock segments + */ + private fun checkForSegments() { + Handler(Looper.getMainLooper()).postDelayed(this::checkForSegments, 100) + + if (segmentData == null || segmentData!!.segments.isEmpty()) return + + segmentData!!.segments.forEach { segment: Segment -> + val segmentStart = (segment.segment!![0] * 1000f).toLong() + val segmentEnd = (segment.segment[1] * 1000f).toLong() + val currentPosition = player?.currentPosition + if (currentPosition in segmentStart until segmentEnd) { + if (PreferenceHelper.getBoolean( + "sb_notifications_key", + true + ) + ) { + try { + Toast.makeText(this, R.string.segment_skipped, Toast.LENGTH_SHORT) + .show() + } catch (e: Exception) { + // Do nothing. + } + } + player?.seekTo(segmentEnd) + } + } + } + + /** + * destroy the [BackgroundMode] foreground service + */ + override fun onDestroy() { + // called when the user pressed stop in the notification + // stop the service from being in the foreground and remove the notification + stopForeground(true) + // destroy the service + stopSelf() + if (this::nowPlayingNotification.isInitialized) nowPlayingNotification.destroy() + super.onDestroy() + } + + override fun onBind(p0: Intent?): IBinder? { + return null + } +} diff --git a/app/src/main/java/com/github/libretube/services/ClosingService.kt b/app/src/main/java/com/github/libretube/services/ClosingService.kt index 716e6dff9..b54debbdc 100644 --- a/app/src/main/java/com/github/libretube/services/ClosingService.kt +++ b/app/src/main/java/com/github/libretube/services/ClosingService.kt @@ -5,8 +5,8 @@ import android.app.Service import android.content.Context import android.content.Intent import android.os.IBinder -import android.util.Log import androidx.annotation.Nullable +import com.github.libretube.PLAYER_NOTIFICATION_ID class ClosingService : Service() { private val TAG = "ClosingService" @@ -20,10 +20,9 @@ class ClosingService : Service() { override fun onTaskRemoved(rootIntent: Intent?) { super.onTaskRemoved(rootIntent) - // destroy all notifications (especially the player notification) + // destroy the player notification when the app gets destroyed val nManager = this.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - nManager.cancelAll() - Log.e(TAG, "closed") + nManager.cancel(PLAYER_NOTIFICATION_ID) // Destroy the service stopSelf() diff --git a/app/src/main/java/com/github/libretube/services/DownloadService.kt b/app/src/main/java/com/github/libretube/services/DownloadService.kt index 63e1ffe70..a0d7c5cf3 100644 --- a/app/src/main/java/com/github/libretube/services/DownloadService.kt +++ b/app/src/main/java/com/github/libretube/services/DownloadService.kt @@ -17,25 +17,26 @@ import android.os.IBinder import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat -import com.arthenica.ffmpegkit.FFmpegKit +import com.github.libretube.DOWNLOAD_CHANNEL_ID +import com.github.libretube.DOWNLOAD_FAILURE_NOTIFICATION_ID +import com.github.libretube.DOWNLOAD_PENDING_NOTIFICATION_ID +import com.github.libretube.DOWNLOAD_SUCCESS_NOTIFICATION_ID +import com.github.libretube.Globals import com.github.libretube.R import com.github.libretube.obj.DownloadType import com.github.libretube.preferences.PreferenceHelper +import com.github.libretube.preferences.PreferenceKeys import java.io.File -var IS_DOWNLOAD_RUNNING = false - class DownloadService : Service() { val TAG = "DownloadService" private lateinit var notification: NotificationCompat.Builder private var downloadId: Long = -1 - private lateinit var videoId: String + private lateinit var videoName: String private lateinit var videoUrl: String private lateinit var audioUrl: String - private lateinit var extension: String - private var duration: Int = 0 private var downloadType: Int = 3 private lateinit var audioDir: File @@ -44,17 +45,15 @@ class DownloadService : Service() { private lateinit var tempDir: File override fun onCreate() { super.onCreate() - IS_DOWNLOAD_RUNNING = true + Globals.IS_DOWNLOAD_RUNNING = true } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - videoId = intent?.getStringExtra("videoId")!! + videoName = intent?.getStringExtra("videoName")!! videoUrl = intent.getStringExtra("videoUrl")!! audioUrl = intent.getStringExtra("audioUrl")!! - duration = intent.getIntExtra("duration", 1) - extension = PreferenceHelper.getString(this, "video_format", ".mp4")!! - downloadType = if (audioUrl != "" && videoUrl != "") DownloadType.MUX - else if (audioUrl != "") DownloadType.AUDIO + + downloadType = if (audioUrl != "") DownloadType.AUDIO else if (videoUrl != "") DownloadType.VIDEO else DownloadType.NONE if (downloadType != DownloadType.NONE) { @@ -86,8 +85,8 @@ class DownloadService : Service() { Log.e(TAG, "Directory already have") } - val downloadLocationPref = PreferenceHelper.getString(this, "download_location", "") - val folderName = PreferenceHelper.getString(this, "download_folder", "LibreTube") + val downloadLocationPref = PreferenceHelper.getString(PreferenceKeys.DOWNLOAD_LOCATION, "") + val folderName = PreferenceHelper.getString(PreferenceKeys.DOWNLOAD_FOLDER, "LibreTube") val location = when (downloadLocationPref) { "downloads" -> Environment.getExternalStoragePublicDirectory(DIRECTORY_DOWNLOADS) @@ -111,18 +110,8 @@ class DownloadService : Service() { IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE) ) when (downloadType) { - DownloadType.MUX -> { - audioDir = File(tempDir, "$videoId-audio") - videoDir = File(tempDir, "$videoId-video") - downloadId = downloadManagerRequest( - getString(R.string.video), - getString(R.string.downloading), - videoUrl, - videoDir - ) - } DownloadType.VIDEO -> { - videoDir = File(libretubeDir, "$videoId-video") + videoDir = File(libretubeDir, videoName) downloadId = downloadManagerRequest( getString(R.string.video), getString(R.string.downloading), @@ -131,7 +120,7 @@ class DownloadService : Service() { ) } DownloadType.AUDIO -> { - audioDir = File(libretubeDir, "$videoId-audio") + audioDir = File(libretubeDir, videoName) downloadId = downloadManagerRequest( getString(R.string.audio), getString(R.string.downloading), @@ -142,6 +131,7 @@ class DownloadService : Service() { } } catch (e: IllegalArgumentException) { Log.e(TAG, "download error $e") + downloadFailedNotification() } } @@ -162,8 +152,6 @@ class DownloadService : Service() { downloadSucceededNotification() onDestroy() } - } else { - muxDownloadedMedia() } } } @@ -196,7 +184,7 @@ class DownloadService : Service() { } // Creating a notification and setting its various attributes notification = - NotificationCompat.Builder(this@DownloadService, "download_service") + NotificationCompat.Builder(this@DownloadService, DOWNLOAD_CHANNEL_ID) .setSmallIcon(R.drawable.ic_download) .setContentTitle("LibreTube") .setContentText(getString(R.string.downloading)) @@ -206,70 +194,31 @@ class DownloadService : Service() { .setProgress(100, 0, true) .setContentIntent(pendingIntent) .setAutoCancel(true) - startForeground(2, notification.build()) + startForeground(DOWNLOAD_PENDING_NOTIFICATION_ID, notification.build()) } private fun downloadFailedNotification() { - val builder = NotificationCompat.Builder(this@DownloadService, "download_service") + val builder = NotificationCompat.Builder(this@DownloadService, DOWNLOAD_CHANNEL_ID) .setSmallIcon(R.drawable.ic_download) .setContentTitle(resources.getString(R.string.downloadfailed)) .setContentText(getString(R.string.fail)) .setPriority(NotificationCompat.PRIORITY_HIGH) with(NotificationManagerCompat.from(this@DownloadService)) { // notificationId is a unique int for each notification that you must define - notify(3, builder.build()) + notify(DOWNLOAD_FAILURE_NOTIFICATION_ID, builder.build()) } } private fun downloadSucceededNotification() { Log.i(TAG, "Download succeeded") - val builder = NotificationCompat.Builder(this@DownloadService, "download_service") + val builder = NotificationCompat.Builder(this@DownloadService, DOWNLOAD_CHANNEL_ID) .setSmallIcon(R.drawable.ic_download) .setContentTitle(resources.getString(R.string.success)) - .setContentText(getString(R.string.fail)) + .setContentText(getString(R.string.downloadsucceeded)) .setPriority(NotificationCompat.PRIORITY_HIGH) with(NotificationManagerCompat.from(this@DownloadService)) { // notificationId is a unique int for each notification that you must define - notify(4, builder.build()) - } - } - - private fun muxDownloadedMedia() { - val command = "-y -i $videoDir -i $audioDir -c copy $libretubeDir/${videoId}$extension" - notification.setContentTitle("Muxing") - FFmpegKit.executeAsync( - command, - { session -> - val state = session.state - val returnCode = session.returnCode - // CALLED WHEN SESSION IS EXECUTED - Log.d( - TAG, - String.format( - "FFmpeg process exited with state %s and rc %s.%s", - state, - returnCode, - session.failStackTrace - ) - ) - tempDir.deleteRecursively() - if (returnCode.toString() != "0") downloadFailedNotification() - else downloadSucceededNotification() - onDestroy() - }, - { - // CALLED WHEN SESSION PRINTS LOGS - Log.e(TAG, it.message.toString()) - } - ) { - // CALLED WHEN SESSION GENERATES STATISTICS - Log.e(TAG + "stat", it.time.toString()) - /*val progress = it.time/(10*duration!!) - if (progress<1){ - notification - .setProgress(progressMax, progress.toInt(), false) - service.notify(1,notification.build()) - }*/ + notify(DOWNLOAD_SUCCESS_NOTIFICATION_ID, builder.build()) } } @@ -279,7 +228,7 @@ class DownloadService : Service() { } catch (e: Exception) { } - IS_DOWNLOAD_RUNNING = false + Globals.IS_DOWNLOAD_RUNNING = false Log.d(TAG, "dl finished!") stopForeground(true) stopService(Intent(this@DownloadService, DownloadService::class.java)) diff --git a/app/src/main/java/com/github/libretube/services/UpdateService.kt b/app/src/main/java/com/github/libretube/services/UpdateService.kt new file mode 100644 index 000000000..5c34cf38b --- /dev/null +++ b/app/src/main/java/com/github/libretube/services/UpdateService.kt @@ -0,0 +1,89 @@ +package com.github.libretube.services + +import android.app.DownloadManager +import android.app.Service +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.net.Uri +import android.os.Environment +import android.os.IBinder +import android.widget.Toast +import com.github.libretube.R +import java.io.File + +class UpdateService : Service() { + private val TAG = "UpdateService" + private lateinit var downloadUrl: String + private var downloadId: Long = -1 + private lateinit var file: File + private lateinit var downloadManager: DownloadManager + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + downloadUrl = intent?.getStringExtra("downloadUrl")!! + + downloadApk(downloadUrl) + + return super.onStartCommand(intent, flags, startId) + } + + private fun downloadApk(downloadUrl: String) { + val dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + // val dir = applicationContext.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) + file = File(dir, "release.apk") + + val request: DownloadManager.Request = + DownloadManager.Request(Uri.parse(downloadUrl)) + .setTitle(getString(R.string.downloading_apk)) + .setDescription("") + .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE) + .setDestinationUri(Uri.fromFile(file)) + .setAllowedOverMetered(true) + .setAllowedOverRoaming(true) + + downloadManager = + applicationContext.getSystemService(DOWNLOAD_SERVICE) as DownloadManager + + downloadId = downloadManager.enqueue(request) + + // listener for the download to end + registerReceiver( + onDownloadComplete, + IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE) + ) + } + + private val onDownloadComplete: BroadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val id = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1) + if (downloadId == id) { + // install the apk after download finished + val installIntent = Intent(Intent.ACTION_VIEW) + installIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + installIntent.setDataAndType( + Uri.fromFile(file), + downloadManager.getMimeTypeForDownloadedFile(downloadId) + ) + try { + startActivity(installIntent) + } catch (e: Exception) { + Toast.makeText( + context, + R.string.downloadsucceeded, + Toast.LENGTH_SHORT + ).show() + } + } + } + } + + override fun onDestroy() { + unregisterReceiver(onDownloadComplete) + super.onDestroy() + } + + override fun onBind(p0: Intent?): IBinder? { + TODO("Not yet implemented") + } +} diff --git a/app/src/main/java/com/github/libretube/update/Asset.kt b/app/src/main/java/com/github/libretube/update/Asset.kt new file mode 100644 index 000000000..a1e4a2c5e --- /dev/null +++ b/app/src/main/java/com/github/libretube/update/Asset.kt @@ -0,0 +1,20 @@ +package com.github.libretube.update + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties + +@JsonIgnoreProperties(ignoreUnknown = true) +data class Asset( + val browser_download_url: String? = null, + val content_type: String? = null, + val created_at: String? = null, + val download_count: Int? = null, + val id: Int? = null, + val label: Any? = null, + val name: String? = null, + val node_id: String? = null, + val size: Int? = null, + val state: String? = null, + val updated_at: String? = null, + val uploader: Uploader? = null, + val url: String? = null +) diff --git a/app/src/main/java/com/github/libretube/update/Author.kt b/app/src/main/java/com/github/libretube/update/Author.kt new file mode 100644 index 000000000..6b9ecd84d --- /dev/null +++ b/app/src/main/java/com/github/libretube/update/Author.kt @@ -0,0 +1,25 @@ +package com.github.libretube.update + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties + +@JsonIgnoreProperties(ignoreUnknown = true) +data class Author( + val avatar_url: String? = null, + val events_url: String? = null, + val followers_url: String? = null, + val following_url: String? = null, + val gists_url: String? = null, + val gravatar_id: String? = null, + val html_url: String? = null, + val id: Int? = null, + val login: String? = null, + val node_id: String? = null, + val organizations_url: String? = null, + val received_events_url: String? = null, + val repos_url: String? = null, + val site_admin: Boolean? = null, + val starred_url: String? = null, + val subscriptions_url: String? = null, + val type: String? = null, + val url: String? = null +) diff --git a/app/src/main/java/com/github/libretube/update/Reactions.kt b/app/src/main/java/com/github/libretube/update/Reactions.kt new file mode 100644 index 000000000..9a87fc84d --- /dev/null +++ b/app/src/main/java/com/github/libretube/update/Reactions.kt @@ -0,0 +1,15 @@ +package com.github.libretube.update + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties + +@JsonIgnoreProperties(ignoreUnknown = true) +data class Reactions( + val confused: Int? = null, + val eyes: Int? = null, + val heart: Int? = null, + val hooray: Int? = null, + val laugh: Int? = null, + val rocket: Int? = null, + val total_count: Int? = null, + val url: String? = null +) diff --git a/app/src/main/java/com/github/libretube/update/UpdateChecker.kt b/app/src/main/java/com/github/libretube/update/UpdateChecker.kt new file mode 100644 index 000000000..c47641120 --- /dev/null +++ b/app/src/main/java/com/github/libretube/update/UpdateChecker.kt @@ -0,0 +1,36 @@ +package com.github.libretube.update + +import com.fasterxml.jackson.databind.ObjectMapper +import com.github.libretube.GITHUB_API_URL +import java.net.URL + +object UpdateChecker { + fun getLatestReleaseInfo(): UpdateInfo? { + var versionInfo: UpdateInfo? = null + // run http request as thread to make it async + val thread = Thread { + // otherwise crashes without internet + versionInfo = getUpdateInfo() + try { + versionInfo = getUpdateInfo() + } catch (e: Exception) { + } + } + thread.start() + // wait for the thread to finish + thread.join() + + // return the information about the latest version + return versionInfo + } + + private fun getUpdateInfo(): UpdateInfo? { + // get the github API response + val latestVersionApiUrl = URL(GITHUB_API_URL) + val json = latestVersionApiUrl.readText() + + // Parse and return the json data + val mapper = ObjectMapper() + return mapper.readValue(json, UpdateInfo::class.java) + } +} diff --git a/app/src/main/java/com/github/libretube/update/UpdateInfo.kt b/app/src/main/java/com/github/libretube/update/UpdateInfo.kt new file mode 100644 index 000000000..f72391c3e --- /dev/null +++ b/app/src/main/java/com/github/libretube/update/UpdateInfo.kt @@ -0,0 +1,27 @@ +package com.github.libretube.update + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties + +@JsonIgnoreProperties(ignoreUnknown = true) +data class UpdateInfo( + val assets: List? = null, + val assets_url: String? = null, + val author: Author? = null, + val body: String? = null, + val created_at: String? = null, + val draft: Boolean? = null, + val html_url: String? = null, + val id: Int? = null, + val mentions_count: Int? = null, + val name: String? = null, + val node_id: String? = null, + val prerelease: Boolean? = null, + val published_at: String? = null, + val reactions: Reactions? = null, + val tag_name: String? = null, + val tarball_url: String? = null, + val target_commitish: String? = null, + val upload_url: String? = null, + val url: String? = null, + val zipball_url: String? = null +) diff --git a/app/src/main/java/com/github/libretube/update/Uploader.kt b/app/src/main/java/com/github/libretube/update/Uploader.kt new file mode 100644 index 000000000..6c19aff19 --- /dev/null +++ b/app/src/main/java/com/github/libretube/update/Uploader.kt @@ -0,0 +1,25 @@ +package com.github.libretube.update + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties + +@JsonIgnoreProperties(ignoreUnknown = true) +data class Uploader( + val avatar_url: String? = null, + val events_url: String? = null, + val followers_url: String? = null, + val following_url: String? = null, + val gists_url: String? = null, + val gravatar_id: String? = null, + val html_url: String? = null, + val id: Int? = null, + val login: String? = null, + val node_id: String? = null, + val organizations_url: String? = null, + val received_events_url: String? = null, + val repos_url: String? = null, + val site_admin: Boolean? = null, + val starred_url: String? = null, + val subscriptions_url: String? = null, + val type: String? = null, + val url: String? = null +) diff --git a/app/src/main/java/com/github/libretube/util/AutoPlayHelper.kt b/app/src/main/java/com/github/libretube/util/AutoPlayHelper.kt new file mode 100644 index 000000000..d747681ea --- /dev/null +++ b/app/src/main/java/com/github/libretube/util/AutoPlayHelper.kt @@ -0,0 +1,87 @@ +package com.github.libretube.util + +import com.github.libretube.Globals +import com.github.libretube.obj.StreamItem +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class AutoPlayHelper( + private val playlistId: String? +) { + private val TAG = "AutoPlayHelper" + + private val playlistStreamIds = mutableListOf() + private var playlistNextPage: String? = null + + suspend fun getNextVideoId( + currentVideoId: String, + relatedStreams: List + ): String? { + return if (Globals.playingQueue.last() != currentVideoId) { + val currentVideoIndex = Globals.playingQueue.indexOf(currentVideoId) + Globals.playingQueue[currentVideoIndex + 1] + } else if (playlistId == null) getNextTrendingVideoId( + currentVideoId, + relatedStreams + ) else getNextPlaylistVideoId( + currentVideoId + ) + } + + private fun getNextTrendingVideoId(videoId: String, relatedStreams: List): String? { + // don't play a video if it got played before already + var index = 0 + var nextStreamId: String? = null + while (nextStreamId == null || + ( + Globals.playingQueue.contains(nextStreamId) && + Globals.playingQueue.indexOf(videoId) > Globals.playingQueue.indexOf( + nextStreamId + ) + ) + ) { + nextStreamId = relatedStreams[index].url.toID() + if (index + 1 < relatedStreams.size) index += 1 + else break + } + return nextStreamId + } + + private suspend fun getNextPlaylistVideoId(currentVideoId: String): String? { + // if the playlists contain the video, then save the next video as next stream + if (playlistStreamIds.contains(currentVideoId)) { + val index = playlistStreamIds.indexOf(currentVideoId) + // check whether there's a next video + return if (index + 1 < playlistStreamIds.size) playlistStreamIds[index + 1] + else if (playlistNextPage == null) null + else getNextPlaylistVideoId(currentVideoId) + } else if (playlistStreamIds.isEmpty() || playlistNextPage != null) { + // fetch the next page of the playlist + return withContext(Dispatchers.IO) { + // fetch the playlists or its nextPage's videos + val playlist = + if (playlistNextPage == null) RetrofitInstance.authApi.getPlaylist(playlistId!!) + else RetrofitInstance.authApi.getPlaylistNextPage( + playlistId!!, + playlistNextPage!! + ) + // save the playlist urls to the list + playlistStreamIds += playlist.relatedStreams!!.map { it.url.toID() } + // save playlistNextPage for usage if video is not contained + playlistNextPage = playlist.nextpage + return@withContext getNextPlaylistVideoId(currentVideoId) + } + } + // return null when no nextPage is found + return null + } + + fun getNextPlayingQueueVideoId( + currentVideoId: String + ): String? { + return if (Globals.playingQueue.last() != currentVideoId) { + val currentVideoIndex = Globals.playingQueue.indexOf(currentVideoId) + Globals.playingQueue[currentVideoIndex + 1] + } else null + } +} diff --git a/app/src/main/java/com/github/libretube/util/BackgroundHelper.kt b/app/src/main/java/com/github/libretube/util/BackgroundHelper.kt new file mode 100644 index 000000000..5bb55909c --- /dev/null +++ b/app/src/main/java/com/github/libretube/util/BackgroundHelper.kt @@ -0,0 +1,26 @@ +package com.github.libretube.util + +import android.content.Context +import android.content.Intent +import com.github.libretube.services.BackgroundMode + +/** + * Helper for starting a new Instance of the [BackgroundMode] + */ +object BackgroundHelper { + fun playOnBackground( + context: Context, + videoId: String, + position: Long? = null, + playlistId: String? = null + ) { + // create an intent for the background mode service + val intent = Intent(context, BackgroundMode::class.java) + intent.putExtra("videoId", videoId) + if (playlistId != null) intent.putExtra("playlistId", playlistId) + if (position != null) intent.putExtra("position", position) + + // start the background mode as foreground service + context.startForegroundService(intent) + } +} diff --git a/app/src/main/java/com/github/libretube/util/BackgroundMode.kt b/app/src/main/java/com/github/libretube/util/BackgroundMode.kt deleted file mode 100644 index 9a5e1c364..000000000 --- a/app/src/main/java/com/github/libretube/util/BackgroundMode.kt +++ /dev/null @@ -1,187 +0,0 @@ -package com.github.libretube.util - -import android.app.NotificationManager -import android.content.Context -import android.support.v4.media.session.MediaSessionCompat -import com.github.libretube.obj.Streams -import com.github.libretube.preferences.PreferenceHelper -import com.google.android.exoplayer2.C -import com.google.android.exoplayer2.ExoPlayer -import com.google.android.exoplayer2.MediaItem -import com.google.android.exoplayer2.Player -import com.google.android.exoplayer2.audio.AudioAttributes -import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector -import com.google.android.exoplayer2.ui.PlayerNotificationManager -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking - -/** - * Loads the selected video audio in background mode with a notification area. - */ -class BackgroundMode { - /** - * The response that gets when called the Api. - */ - private var response: Streams? = null - - /** - * The [ExoPlayer] player. Followed tutorial [here](https://developer.android.com/codelabs/exoplayer-intro) - */ - private var player: ExoPlayer? = null - private var playWhenReadyPlayer = true - - /** - * The [MediaSessionCompat] for the [response]. - */ - private lateinit var mediaSession: MediaSessionCompat - - /** - * The [MediaSessionConnector] to connect with the [mediaSession] and implement it with the [player]. - */ - private lateinit var mediaSessionConnector: MediaSessionConnector - - /** - * The [PlayerNotificationManager] to load the [mediaSession] content on it. - */ - private var playerNotification: PlayerNotificationManager? = null - - /** - * The [AudioAttributes] handle the audio focus of the [player] - */ - private lateinit var audioAttributes: AudioAttributes - - /** - * Initializes the [player] with the [MediaItem]. - */ - private fun initializePlayer(c: Context) { - audioAttributes = AudioAttributes.Builder() - .setUsage(C.USAGE_MEDIA) - .setContentType(C.CONTENT_TYPE_MUSIC) - .build() - - if (player == null) { - player = ExoPlayer.Builder(c) - .setAudioAttributes(audioAttributes, true) - .build() - } - - /** - * Listens for changed playbackStates (e.g. pause, end) - * Plays the next video when the current one ended - */ - player!!.addListener(object : Player.Listener { - override fun onPlaybackStateChanged(@Player.State state: Int) { - val autoplay = PreferenceHelper.getBoolean(c, "autoplay", false) - if (state == Player.STATE_ENDED) { - if (autoplay) playNextVideo(c) - } - } - }) - setMediaItem(c) - } - - /** - * Plays the first related video to the current (used when the playback of the current video ended) - */ - private fun playNextVideo(c: Context) { - if (response!!.relatedStreams!!.isNotEmpty()) { - val videoId = response!! - .relatedStreams!![0].url!! - .replace("/watch?v=", "") - - // destroy old player and its notification - playerNotification = null - player = null - - // kill old notification - val notificationManager = c.getSystemService(Context.NOTIFICATION_SERVICE) - as NotificationManager - notificationManager.cancel(1) - - // play new video on background - playOnBackgroundMode(c, videoId) - } - } - - /** - * Initializes the [playerNotification] attached to the [player] and shows it. - */ - private fun initializePlayerNotification(c: Context) { - playerNotification = PlayerNotificationManager - .Builder(c, 1, "background_mode") - // set the description of the notification - .setMediaDescriptionAdapter( - DescriptionAdapter( - response?.title!!, - response?.uploader!!, - response?.thumbnailUrl!!, - c - ) - ) - .build() - playerNotification?.apply { - setPlayer(player) - setUseNextAction(false) - setUsePreviousAction(false) - setUseStopAction(true) - setColorized(true) - setMediaSessionToken(mediaSession.sessionToken) - } - } - - /** - * Sets the [MediaItem] with the [response] into the [player]. Also creates a [MediaSessionConnector] - * with the [mediaSession] and attach it to the [player]. - */ - private fun setMediaItem(c: Context) { - response?.let { - val mediaItem = MediaItem.Builder().setUri(it.hls!!).build() - player?.setMediaItem(mediaItem) - } - - mediaSession = MediaSessionCompat(c, this.javaClass.name) - mediaSession.isActive = true - - mediaSessionConnector = MediaSessionConnector(mediaSession) - mediaSessionConnector.setPlayer(player) - } - - /** - * Gets the video data and prepares the [player]. - */ - fun playOnBackgroundMode( - c: Context, - videoId: String, - seekToPosition: Long = 0 - ) { - runBlocking { - val job = launch { - response = RetrofitInstance.api.getStreams(videoId) - } - // Wait until the job is done, to load correctly later in the player - job.join() - - initializePlayer(c) - initializePlayerNotification(c) - - player?.apply { - playWhenReady = playWhenReadyPlayer - prepare() - } - - if (!seekToPosition.equals(0)) player?.seekTo(seekToPosition) - } - } - - /** - * Creates a singleton of this class, to not create a new [player] every time. - */ - companion object { - private var INSTANCE: BackgroundMode? = null - - fun getInstance(): BackgroundMode { - if (INSTANCE == null) INSTANCE = BackgroundMode() - return INSTANCE!! - } - } -} diff --git a/app/src/main/java/com/github/libretube/util/ConnectionHelper.kt b/app/src/main/java/com/github/libretube/util/ConnectionHelper.kt index 1ba230baa..6f1e93bb3 100644 --- a/app/src/main/java/com/github/libretube/util/ConnectionHelper.kt +++ b/app/src/main/java/com/github/libretube/util/ConnectionHelper.kt @@ -2,6 +2,10 @@ package com.github.libretube.util import android.content.Context import android.net.ConnectivityManager +import android.widget.ImageView +import coil.ImageLoader +import coil.load +import com.github.libretube.Globals object ConnectionHelper { fun isNetworkAvailable(context: Context): Boolean { @@ -33,4 +37,14 @@ object ConnectionHelper { return connectivityManager.activeNetworkInfo?.isConnected ?: false } + + lateinit var imageLoader: ImageLoader + + // load an image from a url into an imageView + fun loadImage(url: String?, target: ImageView) { + // only load the image if the data saver mode is disabled + if (!Globals.DATA_SAVER_MODE_ENABLED) { + target.load(url, imageLoader) + } + } } diff --git a/app/src/main/java/com/github/libretube/util/CronetHelper.kt b/app/src/main/java/com/github/libretube/util/CronetHelper.kt index 61bb62e75..cc50a9fa9 100644 --- a/app/src/main/java/com/github/libretube/util/CronetHelper.kt +++ b/app/src/main/java/com/github/libretube/util/CronetHelper.kt @@ -1,16 +1,20 @@ package com.github.libretube.util import android.content.Context +import com.google.net.cronet.okhttptransport.CronetCallFactory import org.chromium.net.CronetEngine class CronetHelper { companion object { private lateinit var engine: CronetEngine + lateinit var callFactory: CronetCallFactory fun initCronet(context: Context) { this.engine = CronetEngine.Builder(context) .enableBrotli(true) .build() + callFactory = CronetCallFactory.newBuilder(this.engine) + .build() } fun getCronetEngine(): CronetEngine { diff --git a/app/src/main/java/com/github/libretube/util/DescriptionAdapter.kt b/app/src/main/java/com/github/libretube/util/DescriptionAdapter.kt deleted file mode 100644 index 6bc27bf40..000000000 --- a/app/src/main/java/com/github/libretube/util/DescriptionAdapter.kt +++ /dev/null @@ -1,95 +0,0 @@ -package com.github.libretube.util - -import android.app.PendingIntent -import android.content.Context -import android.content.Intent -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import com.github.libretube.activities.MainActivity -import com.google.android.exoplayer2.Player -import com.google.android.exoplayer2.ui.PlayerNotificationManager -import java.net.URL - -/** - * The [DescriptionAdapter] is used to show title, uploaderName and thumbnail of the video in the notification - * Basic example [here](https://github.com/AnthonyMarkD/AudioPlayerSampleTest) - */ -class DescriptionAdapter( - private val title: String, - private val channelName: String, - private val thumbnailUrl: String, - private val context: Context -) : - PlayerNotificationManager.MediaDescriptionAdapter { - /** - * sets the title of the notification - */ - override fun getCurrentContentTitle(player: Player): CharSequence { - // return controller.metadata.description.title.toString() - return title - } - - /** - * overrides the action when clicking the notification - */ - override fun createCurrentContentIntent(player: Player): PendingIntent? { - // return controller.sessionActivity - /** - * starts a new MainActivity Intent when the player notification is clicked - * it doesn't start a completely new MainActivity because the MainActivity's launchMode - * is set to "singleTop" in the AndroidManifest (important!!!) - * that's the only way to launch back into the previous activity (e.g. the player view - */ - val intent = Intent(context, MainActivity::class.java) - return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE) - } - - /** - * the description of the notification (below the title) - */ - override fun getCurrentContentText(player: Player): CharSequence? { - // return controller.metadata.description.subtitle.toString() - return channelName - } - - /** - * return the icon/thumbnail of the video - */ - override fun getCurrentLargeIcon( - player: Player, - callback: PlayerNotificationManager.BitmapCallback - ): Bitmap? { - lateinit var bitmap: Bitmap - - /** - * running on a new thread to prevent a NetworkMainThreadException - */ - val thread = Thread { - try { - /** - * try to GET the thumbnail from the URL - */ - val inputStream = URL(thumbnailUrl).openStream() - bitmap = BitmapFactory.decodeStream(inputStream) - } catch (ex: java.lang.Exception) { - ex.printStackTrace() - } - } - thread.start() - thread.join() - /** - * returns the scaled bitmap if it got fetched successfully - */ - return try { - val resizedBitmap = Bitmap.createScaledBitmap( - bitmap, - bitmap.width, - bitmap.width, - false - ) - resizedBitmap - } catch (e: Exception) { - null - } - } -} diff --git a/app/src/main/java/com/github/libretube/util/DoubleTapListener.kt b/app/src/main/java/com/github/libretube/util/DoubleTapListener.kt new file mode 100644 index 000000000..4374b9597 --- /dev/null +++ b/app/src/main/java/com/github/libretube/util/DoubleTapListener.kt @@ -0,0 +1,45 @@ +package com.github.libretube.util + +import android.os.Handler +import android.os.Looper +import android.os.SystemClock +import android.view.View + +abstract class DoubleTapListener : View.OnClickListener { + + private var isSingleEvent = false + private val doubleClickQualificationSpanInMillis: Long + private var timestampLastClick: Long + private val handler: Handler + private val runnable: Runnable + + override fun onClick(v: View?) { + if (SystemClock.elapsedRealtime() - timestampLastClick < doubleClickQualificationSpanInMillis) { + isSingleEvent = false + handler.removeCallbacks(runnable) + onDoubleClick() + return + } + isSingleEvent = true + handler.postDelayed(runnable, DEFAULT_QUALIFICATION_SPAN) + timestampLastClick = SystemClock.elapsedRealtime() + } + + abstract fun onDoubleClick() + abstract fun onSingleClick() + + companion object { + private const val DEFAULT_QUALIFICATION_SPAN: Long = 200 + } + + init { + doubleClickQualificationSpanInMillis = DEFAULT_QUALIFICATION_SPAN + timestampLastClick = 0 + handler = Handler(Looper.getMainLooper()) + runnable = Runnable { + if (isSingleEvent) { + onSingleClick() + } + } + } +} diff --git a/app/src/main/java/com/github/libretube/util/ExceptionHandler.kt b/app/src/main/java/com/github/libretube/util/ExceptionHandler.kt new file mode 100644 index 000000000..af8185c84 --- /dev/null +++ b/app/src/main/java/com/github/libretube/util/ExceptionHandler.kt @@ -0,0 +1,14 @@ +package com.github.libretube.util + +import com.github.libretube.preferences.PreferenceHelper + +class ExceptionHandler( + private val defaultExceptionHandler: Thread.UncaughtExceptionHandler? +) : Thread.UncaughtExceptionHandler { + override fun uncaughtException(thread: Thread, exc: Throwable) { + // save the error log + PreferenceHelper.saveErrorLog(exc.stackTraceToString()) + // throw the exception with the default exception handler to make the app crash + defaultExceptionHandler?.uncaughtException(thread, exc) + } +} diff --git a/app/src/main/java/com/github/libretube/util/ImportHelper.kt b/app/src/main/java/com/github/libretube/util/ImportHelper.kt new file mode 100644 index 000000000..2ca3852a5 --- /dev/null +++ b/app/src/main/java/com/github/libretube/util/ImportHelper.kt @@ -0,0 +1,129 @@ +package com.github.libretube.util + +import android.app.Activity +import android.net.Uri +import android.util.Log +import android.widget.Toast +import com.fasterxml.jackson.databind.ObjectMapper +import com.github.libretube.R +import com.github.libretube.obj.NewPipeSubscription +import com.github.libretube.obj.NewPipeSubscriptions +import com.github.libretube.preferences.PreferenceHelper +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import java.io.BufferedReader +import java.io.FileOutputStream +import java.io.InputStreamReader + +class ImportHelper( + private val activity: Activity +) { + private val TAG = "ImportHelper" + + /** + * Import subscriptions by a file uri + */ + fun importSubscriptions(uri: Uri?) { + if (uri == null) return + try { + var channels = ArrayList() + val fileType = activity.contentResolver.getType(uri) + + if (fileType == "application/json") { + // NewPipe subscriptions format + val mapper = ObjectMapper() + val json = readRawTextFromUri(uri) + + val subscriptions = mapper.readValue(json, NewPipeSubscriptions::class.java) + channels = subscriptions.subscriptions?.map { + it.url?.replace("https://www.youtube.com/channel/", "")!! + } as ArrayList + } else if ( + fileType == "text/csv" || + fileType == "text/comma-separated-values" + ) { + // import subscriptions from Google/YouTube Takeout + val inputStream = activity.contentResolver.openInputStream(uri) + BufferedReader(InputStreamReader(inputStream)).use { reader -> + var line: String? = reader.readLine() + while (line != null) { + val channelId = line.substringBefore(",") + if (channelId.length == 24) channels.add(channelId) + line = reader.readLine() + } + } + inputStream?.close() + } else { + throw IllegalArgumentException("Unsupported file type") + } + + CoroutineScope(Dispatchers.IO).launch { + SubscriptionHelper.importSubscriptions(channels) + } + + Toast.makeText(activity, R.string.importsuccess, Toast.LENGTH_SHORT).show() + } catch (e: Exception) { + Log.e(TAG, e.toString()) + Toast.makeText( + activity, + R.string.error, + Toast.LENGTH_SHORT + ).show() + } + } + + private fun readRawTextFromUri(uri: Uri): String { + val stringBuilder = StringBuilder() + activity.contentResolver.openInputStream(uri)?.use { inputStream -> + BufferedReader(InputStreamReader(inputStream)).use { reader -> + var line: String? = reader.readLine() + while (line != null) { + stringBuilder.append(line) + line = reader.readLine() + } + } + } + return stringBuilder.toString() + } + + /** + * write the text to the document + */ + fun exportSubscriptions(uri: Uri?) { + if (uri == null) return + try { + val mapper = ObjectMapper() + val token = PreferenceHelper.getToken() + runBlocking { + val subs = if (token != "") RetrofitInstance.authApi.subscriptions(token) + else RetrofitInstance.authApi.unauthenticatedSubscriptions( + SubscriptionHelper.getFormattedLocalSubscriptions() + ) + val newPipeChannels = mutableListOf() + subs.forEach { + newPipeChannels += NewPipeSubscription( + name = it.name, + service_id = 0, + url = "https://www.youtube.com" + it.url + ) + } + + val newPipeSubscriptions = NewPipeSubscriptions( + subscriptions = newPipeChannels + ) + + val data = mapper.writeValueAsBytes(newPipeSubscriptions) + + activity.contentResolver.openFileDescriptor(uri, "w")?.use { + FileOutputStream(it.fileDescriptor).use { fileOutputStream -> + fileOutputStream.write(data) + } + } + } + } catch (e: Exception) { + e.printStackTrace() + } + } +} diff --git a/app/src/main/java/com/github/libretube/util/LocaleHelper.kt b/app/src/main/java/com/github/libretube/util/LocaleHelper.kt index 8c087a1e5..a638c37b0 100644 --- a/app/src/main/java/com/github/libretube/util/LocaleHelper.kt +++ b/app/src/main/java/com/github/libretube/util/LocaleHelper.kt @@ -4,22 +4,24 @@ import android.content.Context import android.os.Build import android.telephony.TelephonyManager import com.github.libretube.preferences.PreferenceHelper +import com.github.libretube.preferences.PreferenceKeys import java.util.* object LocaleHelper { fun updateLanguage(context: Context) { - val languageName = PreferenceHelper.getString(context, "language", "sys") + val languageName = PreferenceHelper.getString(PreferenceKeys.LANGUAGE, "sys") if (languageName == "sys") updateLocaleConf(context, Locale.getDefault()) - else if ("$languageName".length < 3) { - val locale = Locale(languageName.toString()) - updateLocaleConf(context, locale) - } else if ("$languageName".length > 3) { + else if (languageName?.contains("-") == true) { + val languageParts = languageName.split("-") val locale = Locale( - languageName?.substring(0, 2).toString(), - languageName?.substring(4, 6).toString() + languageParts[0], + languageParts[1] ) updateLocaleConf(context, locale) + } else { + val locale = Locale(languageName.toString()) + updateLocaleConf(context, locale) } } diff --git a/app/src/main/java/com/github/libretube/util/NavigationHelper.kt b/app/src/main/java/com/github/libretube/util/NavigationHelper.kt new file mode 100644 index 000000000..219aa3dac --- /dev/null +++ b/app/src/main/java/com/github/libretube/util/NavigationHelper.kt @@ -0,0 +1,68 @@ +package com.github.libretube.util + +import android.content.Context +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import androidx.constraintlayout.motion.widget.MotionLayout +import androidx.core.os.bundleOf +import com.github.libretube.R +import com.github.libretube.activities.MainActivity +import com.github.libretube.fragments.PlayerFragment + +object NavigationHelper { + fun navigateChannel( + context: Context, + channelId: String? + ) { + if (channelId != null) { + val activity = context as MainActivity + val bundle = bundleOf("channel_id" to channelId) + activity.navController.navigate(R.id.channelFragment, bundle) + try { + val mainMotionLayout = + activity.findViewById(R.id.mainMotionLayout) + if (mainMotionLayout.progress == 0.toFloat()) { + mainMotionLayout.transitionToEnd() + activity.findViewById(R.id.playerMotionLayout) + .transitionToEnd() + } + } catch (e: Exception) { + } + } + } + + fun navigateVideo( + context: Context, + videoId: String?, + playlistId: String? = null + ) { + if (videoId != null) { + val bundle = Bundle() + bundle.putString("videoId", videoId.toID()) + if (playlistId != null) bundle.putString("playlistId", playlistId) + val frag = PlayerFragment() + frag.arguments = bundle + val activity = context as AppCompatActivity + activity.supportFragmentManager.beginTransaction() + .remove(PlayerFragment()) + .commit() + activity.supportFragmentManager.beginTransaction() + .replace(R.id.container, frag) + .commitNow() + } + } + + fun navigatePlaylist( + context: Context, + playlistId: String?, + isOwner: Boolean + ) { + if (playlistId != null) { + val activity = context as MainActivity + val bundle = Bundle() + bundle.putString("playlist_id", playlistId) + bundle.putBoolean("isOwner", isOwner) + activity.navController.navigate(R.id.playlistFragment, bundle) + } + } +} diff --git a/app/src/main/java/com/github/libretube/util/NotificationHelper.kt b/app/src/main/java/com/github/libretube/util/NotificationHelper.kt new file mode 100644 index 000000000..e649ed93d --- /dev/null +++ b/app/src/main/java/com/github/libretube/util/NotificationHelper.kt @@ -0,0 +1,175 @@ +package com.github.libretube.util + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.work.Constraints +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequest +import androidx.work.WorkManager +import com.github.libretube.PUSH_CHANNEL_ID +import com.github.libretube.PUSH_NOTIFICATION_ID +import com.github.libretube.R +import com.github.libretube.activities.MainActivity +import com.github.libretube.preferences.PreferenceHelper +import com.github.libretube.preferences.PreferenceKeys +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import java.util.concurrent.TimeUnit + +object NotificationHelper { + fun enqueueWork( + context: Context, + existingPeriodicWorkPolicy: ExistingPeriodicWorkPolicy + ) { + // get the notification preferences + PreferenceHelper.setContext(context) + val notificationsEnabled = PreferenceHelper.getBoolean( + PreferenceKeys.NOTIFICATION_ENABLED, + true + ) + + val checkingFrequency = PreferenceHelper.getString( + PreferenceKeys.CHECKING_FREQUENCY, + "60" + ).toLong() + + val uniqueWorkName = "NotificationService" + + // schedule the work manager request if logged in and notifications enabled + if (notificationsEnabled && PreferenceHelper.getToken() != "") { + // required network type for the work + val networkType = when ( + PreferenceHelper.getString(PreferenceKeys.REQUIRED_NETWORK, "all") + ) { + "all" -> NetworkType.CONNECTED + "wifi" -> NetworkType.UNMETERED + "metered" -> NetworkType.METERED + else -> NetworkType.CONNECTED + } + + // requirements for the work + // here: network needed to run the task + val constraints = Constraints.Builder() + .setRequiredNetworkType(networkType) + .build() + + // create the worker + val notificationWorker = PeriodicWorkRequest.Builder( + NotificationWorker::class.java, + checkingFrequency, + TimeUnit.MINUTES + ) + .setConstraints(constraints) + .build() + + // enqueue the task + WorkManager.getInstance(context) + .enqueueUniquePeriodicWork( + uniqueWorkName, + existingPeriodicWorkPolicy, + notificationWorker + ) + } else { + // cancel the work if notifications are disabled or the user is not logged in + WorkManager.getInstance(context) + .cancelUniqueWork(uniqueWorkName) + } + } + + /** + * check whether new streams are available in subscriptions + */ + fun checkForNewStreams(context: Context): Boolean { + var result = true + + val token = PreferenceHelper.getToken() + runBlocking { + val task = async { + if (token != "") RetrofitInstance.authApi.getFeed(token) + else RetrofitInstance.authApi.getUnauthenticatedFeed( + SubscriptionHelper.getFormattedLocalSubscriptions() + ) + } + // fetch the users feed + val videoFeed = try { + task.await() + } catch (e: Exception) { + result = false + return@runBlocking + } + + val lastSeenStreamId = PreferenceHelper.getLatestVideoId() + val latestFeedStreamId = videoFeed[0].url.toID() + + // first time notifications enabled + if (lastSeenStreamId == "") PreferenceHelper.setLatestVideoId(lastSeenStreamId) + else if (lastSeenStreamId != latestFeedStreamId) { + // get the index of the last user-seen video + var newStreamIndex = -1 + videoFeed.forEachIndexed { index, stream -> + if (stream.url?.toID() == lastSeenStreamId) { + newStreamIndex = index + } + } + if (newStreamIndex == -1) return@runBlocking + val (title, description) = when (newStreamIndex) { + // only one new stream available + 1 -> { + Pair(videoFeed[0].title, videoFeed[0].uploaderName) + } + else -> { + Pair( + // return the amount of new streams as title + context.getString( + R.string.new_streams_count, + newStreamIndex.toString() + ), + // return the first few uploader as description + context.getString( + R.string.new_streams_by, + videoFeed[0].uploaderName + ", " + videoFeed[1].uploaderName + ", " + videoFeed[2].uploaderName + ) + ) + } + } + // save the id of the last recent video for the next time it's running + PreferenceHelper.setLatestVideoId(videoFeed[0].url.toID()) + createNotification(context, title!!, description!!) + } + } + // return whether the work succeeded + return result + } + + /** + * Notification that is created when new streams are found + */ + fun createNotification(context: Context, title: String, description: String) { + val intent = Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + val pendingIntent: PendingIntent = PendingIntent.getActivity( + context, + 0, + intent, + PendingIntent.FLAG_IMMUTABLE + ) + + val builder = NotificationCompat.Builder(context, PUSH_CHANNEL_ID) + .setContentTitle(title) + .setSmallIcon(R.drawable.ic_bell) + .setContentText(description) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + // Set the intent that will fire when the user taps the notification + .setContentIntent(pendingIntent) + .setAutoCancel(true) + with(NotificationManagerCompat.from(context)) { + // notificationId is a unique int for each notification that you must define + notify(PUSH_NOTIFICATION_ID, builder.build()) + } + } +} diff --git a/app/src/main/java/com/github/libretube/util/NotificationWorker.kt b/app/src/main/java/com/github/libretube/util/NotificationWorker.kt new file mode 100644 index 000000000..027867b1b --- /dev/null +++ b/app/src/main/java/com/github/libretube/util/NotificationWorker.kt @@ -0,0 +1,20 @@ +package com.github.libretube.util + +import android.content.Context +import androidx.work.Worker +import androidx.work.WorkerParameters + +/** + * The notification worker which checks for new streams in a certain frequency + */ +class NotificationWorker(appContext: Context, parameters: WorkerParameters) : + Worker(appContext, parameters) { + private val TAG = "NotificationWorker" + + override fun doWork(): Result { + // check whether there are new streams and notify if there are some + val result = NotificationHelper.checkForNewStreams(applicationContext) + // return success if the API request succeeded + return if (result) Result.success() else Result.retry() + } +} diff --git a/app/src/main/java/com/github/libretube/util/NowPlayingNotification.kt b/app/src/main/java/com/github/libretube/util/NowPlayingNotification.kt new file mode 100644 index 000000000..f0f52809c --- /dev/null +++ b/app/src/main/java/com/github/libretube/util/NowPlayingNotification.kt @@ -0,0 +1,180 @@ +package com.github.libretube.util + +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.support.v4.media.session.MediaSessionCompat +import com.github.libretube.BACKGROUND_CHANNEL_ID +import com.github.libretube.PLAYER_NOTIFICATION_ID +import com.github.libretube.activities.MainActivity +import com.github.libretube.obj.Streams +import com.google.android.exoplayer2.ExoPlayer +import com.google.android.exoplayer2.Player +import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector +import com.google.android.exoplayer2.ui.PlayerNotificationManager +import java.net.URL + +class NowPlayingNotification( + private val context: Context, + private val player: ExoPlayer +) { + private var streams: Streams? = null + + /** + * The [MediaSessionCompat] for the [streams]. + */ + private lateinit var mediaSession: MediaSessionCompat + + /** + * The [MediaSessionConnector] to connect with the [mediaSession] and implement it with the [player]. + */ + private lateinit var mediaSessionConnector: MediaSessionConnector + + /** + * The [PlayerNotificationManager] to load the [mediaSession] content on it. + */ + private var playerNotification: PlayerNotificationManager? = null + + /** + * The [DescriptionAdapter] is used to show title, uploaderName and thumbnail of the video in the notification + * Basic example [here](https://github.com/AnthonyMarkD/AudioPlayerSampleTest) + */ + inner class DescriptionAdapter() : + PlayerNotificationManager.MediaDescriptionAdapter { + /** + * sets the title of the notification + */ + override fun getCurrentContentTitle(player: Player): CharSequence { + // return controller.metadata.description.title.toString() + return streams?.title!! + } + + /** + * overrides the action when clicking the notification + */ + override fun createCurrentContentIntent(player: Player): PendingIntent? { + // return controller.sessionActivity + /** + * starts a new MainActivity Intent when the player notification is clicked + * it doesn't start a completely new MainActivity because the MainActivity's launchMode + * is set to "singleTop" in the AndroidManifest (important!!!) + * that's the only way to launch back into the previous activity (e.g. the player view + */ + val intent = Intent(context, MainActivity::class.java) + return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE) + } + + /** + * the description of the notification (below the title) + */ + override fun getCurrentContentText(player: Player): CharSequence? { + // return controller.metadata.description.subtitle.toString() + return streams?.uploader + } + + /** + * return the icon/thumbnail of the video + */ + override fun getCurrentLargeIcon( + player: Player, + callback: PlayerNotificationManager.BitmapCallback + ): Bitmap? { + lateinit var bitmap: Bitmap + + /** + * running on a new thread to prevent a NetworkMainThreadException + */ + val thread = Thread { + try { + /** + * try to GET the thumbnail from the URL + */ + val inputStream = URL(streams?.thumbnailUrl).openStream() + bitmap = BitmapFactory.decodeStream(inputStream) + } catch (ex: java.lang.Exception) { + ex.printStackTrace() + } + } + thread.start() + thread.join() + /** + * returns the scaled bitmap if it got fetched successfully + */ + return try { + val resizedBitmap = Bitmap.createScaledBitmap( + bitmap, + bitmap.width, + bitmap.width, + false + ) + resizedBitmap + } catch (e: Exception) { + null + } + } + } + + /** + * Creates a [MediaSessionCompat] amd a [MediaSessionConnector] for the player + */ + private fun createMediaSession() { + if (this::mediaSession.isInitialized) return + mediaSession = MediaSessionCompat(context, this.javaClass.name) + mediaSession.isActive = true + + mediaSessionConnector = MediaSessionConnector(mediaSession) + mediaSessionConnector.setPlayer(player) + } + + /** + * Updates or creates the [playerNotification] + */ + fun updatePlayerNotification( + streams: Streams + ) { + this.streams = streams + if (playerNotification == null) { + createMediaSession() + createNotification() + } + } + + /** + * Initializes the [playerNotification] attached to the [player] and shows it. + */ + private fun createNotification() { + playerNotification = PlayerNotificationManager + .Builder(context, PLAYER_NOTIFICATION_ID, BACKGROUND_CHANNEL_ID) + // set the description of the notification + .setMediaDescriptionAdapter( + DescriptionAdapter() + ) + .build() + playerNotification?.apply { + setPlayer(player) + setUseNextAction(false) + setUsePreviousAction(false) + setUseStopAction(true) + setColorized(true) + setMediaSessionToken(mediaSession.sessionToken) + } + } + + /** + * Destroy the [NowPlayingNotification] + */ + fun destroy() { + mediaSession.isActive = false + mediaSession.release() + mediaSessionConnector.setPlayer(null) + playerNotification?.setPlayer(null) + val notificationManager = context.getSystemService( + Context.NOTIFICATION_SERVICE + ) as NotificationManager + notificationManager.cancel(PLAYER_NOTIFICATION_ID) + player.release() + } +} diff --git a/app/src/main/java/com/github/libretube/util/PermissionHelper.kt b/app/src/main/java/com/github/libretube/util/PermissionHelper.kt new file mode 100644 index 000000000..3670a2d81 --- /dev/null +++ b/app/src/main/java/com/github/libretube/util/PermissionHelper.kt @@ -0,0 +1,68 @@ +package com.github.libretube.util + +import android.Manifest +import android.app.Activity +import android.content.pm.PackageManager +import android.os.Build +import android.os.Environment +import androidx.core.app.ActivityCompat + +object PermissionHelper { + fun requestReadWrite(activity: Activity): Boolean { + // request storage permissions if not granted yet + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + if (!Environment.isExternalStorageManager()) { + ActivityCompat.requestPermissions( + activity, + arrayOf( + Manifest.permission.READ_EXTERNAL_STORAGE, + Manifest.permission.MANAGE_EXTERNAL_STORAGE + ), + 1 + ) // permission request code is just an int + return false + } + } else { + if (ActivityCompat.checkSelfPermission( + activity, + Manifest.permission.READ_EXTERNAL_STORAGE + ) != PackageManager.PERMISSION_GRANTED || + ActivityCompat.checkSelfPermission( + activity, + Manifest.permission.WRITE_EXTERNAL_STORAGE + ) != PackageManager.PERMISSION_GRANTED + ) { + ActivityCompat.requestPermissions( + activity, + arrayOf( + Manifest.permission.READ_EXTERNAL_STORAGE, + Manifest.permission.WRITE_EXTERNAL_STORAGE + ), + 1 + ) + return false + } + } + return true + } + + fun isStoragePermissionGranted(activity: Activity): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + if (activity.checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) + == PackageManager.PERMISSION_GRANTED + ) { + true + } else { + ActivityCompat.requestPermissions( + activity, + arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), + 1 + ) + false + } + } else { + // permission is automatically granted on sdk < 23 upon installation + true + } + } +} diff --git a/app/src/main/java/com/github/libretube/util/PipedApi.kt b/app/src/main/java/com/github/libretube/util/PipedApi.kt index ca0f791a5..36fc4902b 100644 --- a/app/src/main/java/com/github/libretube/util/PipedApi.kt +++ b/app/src/main/java/com/github/libretube/util/PipedApi.kt @@ -66,6 +66,9 @@ interface PipedApi { @GET("channel/{channelId}") suspend fun getChannel(@Path("channelId") channelId: String): Channel + @GET("user/{name}") + suspend fun getChannelByName(@Path("name") channelName: String): Channel + @GET("nextpage/channel/{channelId}") suspend fun getChannelNextPage( @Path("channelId") channelId: String, @@ -96,6 +99,9 @@ interface PipedApi { @GET("feed") suspend fun getFeed(@Query("authToken") token: String?): List + @GET("feed/unauthenticated") + suspend fun getUnauthenticatedFeed(@Query("channels") channels: String): List + @GET("subscribed") suspend fun isSubscribed( @Query("channelId") channelId: String, @@ -105,6 +111,9 @@ interface PipedApi { @GET("subscriptions") suspend fun subscriptions(@Header("Authorization") token: String): List + @GET("subscriptions/unauthenticated") + suspend fun unauthenticatedSubscriptions(@Query("channels") channels: String): List + @POST("subscribe") suspend fun subscribe( @Header("Authorization") token: String, diff --git a/app/src/main/java/com/github/libretube/util/PlayerHelper.kt b/app/src/main/java/com/github/libretube/util/PlayerHelper.kt new file mode 100644 index 000000000..81fcccae5 --- /dev/null +++ b/app/src/main/java/com/github/libretube/util/PlayerHelper.kt @@ -0,0 +1,135 @@ +package com.github.libretube.util + +import android.content.Context +import android.view.accessibility.CaptioningManager +import com.github.libretube.obj.PipedStream +import com.github.libretube.preferences.PreferenceHelper +import com.github.libretube.preferences.PreferenceKeys +import com.google.android.exoplayer2.ui.CaptionStyleCompat + +object PlayerHelper { + private val TAG = "PlayerHelper" + + // get the audio source following the users preferences + fun getAudioSource(audios: List): String { + val audioFormat = PreferenceHelper.getString(PreferenceKeys.PLAYER_AUDIO_FORMAT, "all") + val audioQuality = PreferenceHelper.getString(PreferenceKeys.PLAYER_AUDIO_QUALITY, "best") + + val mutableAudios = audios.toMutableList() + if (audioFormat != "all") { + audios.forEach { + val audioMimeType = "audio/$audioFormat" + if (it.mimeType != audioMimeType) mutableAudios.remove(it) + } + } + + return if (audioQuality == "worst") { + getLeastBitRate(mutableAudios) + } else { + getMostBitRate(mutableAudios) + } + } + + // get the best bit rate from audio streams + private fun getMostBitRate(audios: List): String { + var bitrate = 0 + var audioUrl = "" + audios.forEach { + if (it.bitrate != null && it.bitrate!! > bitrate) { + bitrate = it.bitrate!! + audioUrl = it.url.toString() + } + } + return audioUrl + } + + // get the best bit rate from audio streams + private fun getLeastBitRate(audios: List): String { + var bitrate = 1000000000 + var audioUrl = "" + audios.forEach { + if (it.bitrate != null && it.bitrate!! < bitrate) { + bitrate = it.bitrate!! + audioUrl = it.url.toString() + } + } + return audioUrl + } + + // get the system default caption style + fun getCaptionStyle(context: Context): CaptionStyleCompat { + val captioningManager = + context.getSystemService(Context.CAPTIONING_SERVICE) as CaptioningManager + return if (!captioningManager.isEnabled) { + // system captions are disabled, using android default captions style + CaptionStyleCompat.DEFAULT + } else { + // system captions are enabled + CaptionStyleCompat.createFromCaptionStyle(captioningManager.userStyle) + } + } + + /** + * get the categories for sponsorBlock + */ + fun getSponsorBlockCategories(): ArrayList { + val categories: ArrayList = arrayListOf() + if (PreferenceHelper.getBoolean( + "intro_category_key", + false + ) + ) { + categories.add("intro") + } + if (PreferenceHelper.getBoolean( + "selfpromo_category_key", + false + ) + ) { + categories.add("selfpromo") + } + if (PreferenceHelper.getBoolean( + "interaction_category_key", + false + ) + ) { + categories.add("interaction") + } + if (PreferenceHelper.getBoolean( + "sponsors_category_key", + true + ) + ) { + categories.add("sponsor") + } + if (PreferenceHelper.getBoolean( + "outro_category_key", + false + ) + ) { + categories.add("outro") + } + if (PreferenceHelper.getBoolean( + "filler_category_key", + false + ) + ) { + categories.add("filler") + } + if (PreferenceHelper.getBoolean( + "music_offtopic_category_key", + false + ) + ) { + categories.add("music_offtopic") + } + if (PreferenceHelper.getBoolean( + "preview_category_key", + false + ) + ) { + categories.add("preview") + } + return categories + } +} diff --git a/app/src/main/java/com/github/libretube/util/RetrofitInstance.kt b/app/src/main/java/com/github/libretube/util/RetrofitInstance.kt index 87caad710..c8a9eaeb7 100644 --- a/app/src/main/java/com/github/libretube/util/RetrofitInstance.kt +++ b/app/src/main/java/com/github/libretube/util/RetrofitInstance.kt @@ -10,6 +10,7 @@ object RetrofitInstance { val api: PipedApi by resettableLazy(lazyMgr) { Retrofit.Builder() .baseUrl(url) + .callFactory(CronetHelper.callFactory) .addConverterFactory(JacksonConverterFactory.create()) .build() .create(PipedApi::class.java) @@ -17,6 +18,7 @@ object RetrofitInstance { val authApi: PipedApi by resettableLazy(lazyMgr) { Retrofit.Builder() .baseUrl(authUrl) + .callFactory(CronetHelper.callFactory) .addConverterFactory(JacksonConverterFactory.create()) .build() .create(PipedApi::class.java) diff --git a/app/src/main/java/com/github/libretube/util/SubscriptionHelper.kt b/app/src/main/java/com/github/libretube/util/SubscriptionHelper.kt new file mode 100644 index 000000000..c917d0900 --- /dev/null +++ b/app/src/main/java/com/github/libretube/util/SubscriptionHelper.kt @@ -0,0 +1,92 @@ +package com.github.libretube.util + +import android.util.Log +import com.github.libretube.obj.Subscribe +import com.github.libretube.preferences.PreferenceHelper +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +object SubscriptionHelper { + val TAG = "SubscriptionHelper" + + fun subscribe(channelId: String) { + if (PreferenceHelper.getToken() != "") { + CoroutineScope(Dispatchers.IO).launch { + try { + RetrofitInstance.authApi.subscribe( + PreferenceHelper.getToken(), + Subscribe(channelId) + ) + } catch (e: Exception) { + Log.e(TAG, e.toString()) + } + } + } else { + val channels = PreferenceHelper.getLocalSubscriptions().toMutableList() + channels.add(channelId) + PreferenceHelper.setLocalSubscriptions(channels) + } + } + + fun unsubscribe(channelId: String) { + if (PreferenceHelper.getToken() != "") { + CoroutineScope(Dispatchers.IO).launch { + try { + RetrofitInstance.authApi.unsubscribe( + PreferenceHelper.getToken(), + Subscribe(channelId) + ) + } catch (e: Exception) { + Log.e(TAG, e.toString()) + } + } + } else { + val channels = PreferenceHelper.getLocalSubscriptions().toMutableList() + channels.remove(channelId) + PreferenceHelper.setLocalSubscriptions(channels) + } + } + + suspend fun isSubscribed(channelId: String): Boolean? { + if (PreferenceHelper.getToken() != "") { + val isSubscribed = try { + RetrofitInstance.authApi.isSubscribed( + channelId, + PreferenceHelper.getToken() + ) + } catch (e: Exception) { + Log.e(TAG, e.toString()) + return null + } + return isSubscribed.subscribed + } else { + return PreferenceHelper.getLocalSubscriptions().contains(channelId) + } + } + + suspend fun importSubscriptions(newChannels: List) { + if (PreferenceHelper.getToken() != "") { + try { + val token = PreferenceHelper.getToken() + RetrofitInstance.authApi.importSubscriptions( + false, + token, + newChannels + ) + } catch (e: Exception) { + e.printStackTrace() + } + } else { + val channels = PreferenceHelper.getLocalSubscriptions().toMutableList() + newChannels.forEach { + if (!channels.contains(it)) channels += it + } + PreferenceHelper.setLocalSubscriptions(channels) + } + } + + fun getFormattedLocalSubscriptions(): String { + return PreferenceHelper.getLocalSubscriptions().joinToString(",") + } +} diff --git a/app/src/main/java/com/github/libretube/util/ThemeHelper.kt b/app/src/main/java/com/github/libretube/util/ThemeHelper.kt index f246704c4..952797a37 100644 --- a/app/src/main/java/com/github/libretube/util/ThemeHelper.kt +++ b/app/src/main/java/com/github/libretube/util/ThemeHelper.kt @@ -12,13 +12,14 @@ import androidx.appcompat.app.AppCompatDelegate import androidx.core.text.HtmlCompat import com.github.libretube.R import com.github.libretube.preferences.PreferenceHelper +import com.github.libretube.preferences.PreferenceKeys import com.google.android.material.color.DynamicColors object ThemeHelper { fun updateTheme(activity: AppCompatActivity) { - val themeMode = PreferenceHelper.getString(activity, "theme_toggle", "A")!! - val pureThemeEnabled = PreferenceHelper.getBoolean(activity, "pure_theme", false) + val themeMode = PreferenceHelper.getString(PreferenceKeys.THEME_MODE, "A") + val pureThemeEnabled = PreferenceHelper.getBoolean(PreferenceKeys.PURE_THEME, false) updateAccentColor(activity, pureThemeEnabled) updateThemeMode(themeMode) @@ -30,15 +31,14 @@ object ThemeHelper { ) { val theme = when ( PreferenceHelper.getString( - activity, - "accent_color", + PreferenceKeys.ACCENT_COLOR, "purple" ) ) { "my" -> { applyDynamicColors(activity) - if (pureThemeEnabled) R.style.MaterialYou_Pure - else R.style.MaterialYou + if (pureThemeEnabled) R.style.BaseTheme_Pure + else R.style.BaseTheme } // set the theme, use the pure theme if enabled "red" -> if (pureThemeEnabled) R.style.Theme_Red_Pure else R.style.Theme_Red @@ -72,15 +72,25 @@ object ThemeHelper { val activityAliases = context.resources.getStringArray(R.array.iconsValue) // Disable Old Icon(s) for (activityAlias in activityAliases) { + val activityClass = "com.github.libretube." + + if (activityAlias == activityAliases[0]) "activities.MainActivity" // default icon/activity + else activityAlias + + // remove old icons context.packageManager.setComponentEnabledSetting( - ComponentName(context.packageName, "com.github.libretube.$activityAlias"), + ComponentName(context.packageName, activityClass), PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP ) } + + // set the class name for the activity alias + val newLogoActivityClass = "com.github.libretube." + + if (newLogoActivityAlias == activityAliases[0]) "activities.MainActivity" // default icon/activity + else newLogoActivityAlias // Enable New Icon context.packageManager.setComponentEnabledSetting( - ComponentName(context.packageName, "com.github.libretube.$newLogoActivityAlias"), + ComponentName(context.packageName, newLogoActivityClass), PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP ) @@ -90,13 +100,15 @@ object ThemeHelper { fun restartMainActivity(context: Context) { // kill player notification val nManager = context - .getSystemService(AppCompatActivity.NOTIFICATION_SERVICE) as NotificationManager + .getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager nManager.cancelAll() - // restart to MainActivity + // start a new Intent of the app val pm: PackageManager = context.packageManager val intent = pm.getLaunchIntentForPackage(context.packageName) intent?.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK context.startActivity(intent) + // kill the old application + android.os.Process.killProcess(android.os.Process.myPid()) } fun getThemeColor(context: Context, colorCode: Int): Int { diff --git a/app/src/main/java/com/github/libretube/util/UpdateChecker.kt b/app/src/main/java/com/github/libretube/util/UpdateChecker.kt deleted file mode 100644 index 3513a636d..000000000 --- a/app/src/main/java/com/github/libretube/util/UpdateChecker.kt +++ /dev/null @@ -1,75 +0,0 @@ -package com.github.libretube.util - -import android.util.Log -import androidx.fragment.app.FragmentManager -import com.github.libretube.BuildConfig -import com.github.libretube.GITHUB_API_URL -import com.github.libretube.dialogs.NoUpdateAvailableDialog -import com.github.libretube.dialogs.UpdateAvailableDialog -import com.github.libretube.obj.UpdateInfo -import org.json.JSONArray -import org.json.JSONObject -import java.io.BufferedReader -import java.io.InputStreamReader -import java.net.URL -import javax.net.ssl.HttpsURLConnection - -fun checkUpdate(childFragmentManager: FragmentManager) { - var updateInfo: UpdateInfo? = UpdateInfo("", "") - // run http request as thread to make it async - val thread = Thread { - // otherwise crashes without internet - try { - updateInfo = getUpdateInfo() - } catch (e: Exception) { - } - } - thread.start() - // wait for the thread to finish - thread.join() - // show the UpdateAvailableDialog if there's an update available - if (updateInfo?.tagName != "" && BuildConfig.VERSION_NAME != updateInfo?.tagName) { - val updateAvailableDialog = UpdateAvailableDialog( - updateInfo?.tagName!!, - updateInfo?.updateUrl!! - ) - updateAvailableDialog.show(childFragmentManager, "UpdateAvailableDialog") - } else { - // otherwise show the no update available dialog - val noUpdateAvailableDialog = NoUpdateAvailableDialog() - noUpdateAvailableDialog.show(childFragmentManager, "NoUpdateAvailableDialog") - } -} - -fun getUpdateInfo(): UpdateInfo? { - val latest = URL(GITHUB_API_URL) - val json = StringBuilder() - val urlConnection: HttpsURLConnection? - urlConnection = latest.openConnection() as HttpsURLConnection - val br = BufferedReader(InputStreamReader(urlConnection.inputStream)) - - var line: String? - while (br.readLine().also { line = it } != null) json.append(line) - - // Parse and return the json data - val jsonRoot = JSONObject(json.toString()) - if (jsonRoot.has("tag_name") && - jsonRoot.has("html_url") && - jsonRoot.has("assets") - ) { - val updateUrl = jsonRoot.getString("html_url") - val jsonAssets: JSONArray = jsonRoot.getJSONArray("assets") - for (i in 0 until jsonAssets.length()) { - val jsonAsset = jsonAssets.getJSONObject(i) - if (jsonAsset.has("name")) { - val name = jsonAsset.getString("name") - if (name.endsWith(".apk")) { - val tagName = jsonRoot.getString("name") - Log.i("", "Latest version: $tagName") - return UpdateInfo(updateUrl, tagName) - } - } - } - } - return null -} diff --git a/app/src/main/java/com/github/libretube/views/BottomSheetFragment.kt b/app/src/main/java/com/github/libretube/views/BottomSheetFragment.kt new file mode 100644 index 000000000..48de8f0ef --- /dev/null +++ b/app/src/main/java/com/github/libretube/views/BottomSheetFragment.kt @@ -0,0 +1,61 @@ +package com.github.libretube.views + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.github.libretube.databinding.BottomSheetBinding +import com.github.libretube.interfaces.PlayerOptionsInterface +import com.google.android.material.bottomsheet.BottomSheetDialogFragment + +class BottomSheetFragment : BottomSheetDialogFragment() { + private lateinit var binding: BottomSheetBinding + private lateinit var playerOptionsInterface: PlayerOptionsInterface + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = BottomSheetBinding.inflate(layoutInflater, container, false) + return binding.root + } + + fun setOnClickListeners(playerOptionsInterface: PlayerOptionsInterface) { + this.playerOptionsInterface = playerOptionsInterface + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.aspectRatio.setOnClickListener { + playerOptionsInterface.onAspectRatioClicked() + this.dismiss() + } + + binding.quality.setOnClickListener { + playerOptionsInterface.onQualityClicked() + this.dismiss() + } + + binding.playbackSpeed.setOnClickListener { + playerOptionsInterface.onPlaybackSpeedClicked() + this.dismiss() + } + + binding.captions.setOnClickListener { + playerOptionsInterface.onCaptionClicked() + this.dismiss() + } + + binding.autoplay.setOnClickListener { + playerOptionsInterface.onAutoplayClicked() + this.dismiss() + } + + binding.repeatMode.setOnClickListener { + playerOptionsInterface.onRepeatModeClicked() + this.dismiss() + } + } +} diff --git a/app/src/main/java/com/github/libretube/views/CustomExoPlayerView.kt b/app/src/main/java/com/github/libretube/views/CustomExoPlayerView.kt index a3886f8ce..ccad7cdbd 100644 --- a/app/src/main/java/com/github/libretube/views/CustomExoPlayerView.kt +++ b/app/src/main/java/com/github/libretube/views/CustomExoPlayerView.kt @@ -5,25 +5,53 @@ import android.content.Context import android.util.AttributeSet import android.view.MotionEvent import com.github.libretube.databinding.ExoStyledPlayerControlViewBinding +import com.github.libretube.interfaces.DoubleTapInterface +import com.github.libretube.util.DoubleTapListener import com.google.android.exoplayer2.ui.StyledPlayerView +@SuppressLint("ClickableViewAccessibility") internal class CustomExoPlayerView( context: Context, attributeSet: AttributeSet? = null ) : StyledPlayerView(context, attributeSet) { + val TAG = "CustomExoPlayerView" val binding: ExoStyledPlayerControlViewBinding = ExoStyledPlayerControlViewBinding.bind(this) - @SuppressLint("ClickableViewAccessibility") - override fun onTouchEvent(event: MotionEvent): Boolean { - when (event.action) { - MotionEvent.ACTION_DOWN -> { - if (isControllerFullyVisible) { - hideController() - } else { - showController() - } - } + private var doubleTapListener: DoubleTapInterface? = null + + // the x-position of where the user clicked + private var xPos = 0F + + fun setOnDoubleTapListener( + eventListener: DoubleTapInterface? + ) { + doubleTapListener = eventListener + } + + private fun toggleController() { + if (isControllerFullyVisible) hideController() else showController() + } + + val doubleTouchListener = object : DoubleTapListener() { + override fun onDoubleClick() { + doubleTapListener?.onEvent(xPos) } + + override fun onSingleClick() { + toggleController() + } + } + + init { + // set the double click listener for rewind/forward + setOnClickListener(doubleTouchListener) + } + + override fun onTouchEvent(event: MotionEvent): Boolean { + // save the x position of the touch event + xPos = event.x + // listen for a double touch + doubleTouchListener.onClick(this) return false } } diff --git a/app/src/main/java/com/github/libretube/views/DoubleClickListener.kt b/app/src/main/java/com/github/libretube/views/DoubleClickListener.kt deleted file mode 100644 index 808ef2320..000000000 --- a/app/src/main/java/com/github/libretube/views/DoubleClickListener.kt +++ /dev/null @@ -1,51 +0,0 @@ -package com.github.libretube.views - -import android.os.Handler -import android.os.Looper -import android.view.View - -class DoubleClickListener( - private val doubleClickTimeLimitMills: Long = 200, - private val callback: Callback -) : View.OnClickListener { - private var lastClicked: Long = -1L - - override fun onClick(v: View?) { - lastClicked = when { - lastClicked == -1L -> { - checkForSingleClick() - System.currentTimeMillis() - } - isDoubleClicked() -> { - callback.doubleClicked() - -1L - } - else -> { - checkForSingleClick() - System.currentTimeMillis() - } - } - } - - private fun checkForSingleClick() { - Handler(Looper.getMainLooper()).postDelayed({ - if (lastClicked != -1L) callback.singleClicked() - }, doubleClickTimeLimitMills) - } - - private fun getTimeDiff(from: Long, to: Long): Long { - return to - from - } - - private fun isDoubleClicked(): Boolean { - return getTimeDiff( - lastClicked, - System.currentTimeMillis() - ) <= doubleClickTimeLimitMills - } - - interface Callback { - fun doubleClicked() - fun singleClicked() - } -} diff --git a/app/src/main/java/com/github/libretube/views/DoubleTapOverlay.kt b/app/src/main/java/com/github/libretube/views/DoubleTapOverlay.kt new file mode 100644 index 000000000..bdd2a3fcb --- /dev/null +++ b/app/src/main/java/com/github/libretube/views/DoubleTapOverlay.kt @@ -0,0 +1,19 @@ +package com.github.libretube.views + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.widget.LinearLayout +import com.github.libretube.databinding.DoubleTapOverlayBinding + +class DoubleTapOverlay( + context: Context, + attrs: AttributeSet? = null +) : LinearLayout(context, attrs) { + var binding: DoubleTapOverlayBinding + + init { + val layoutInflater = LayoutInflater.from(context) + binding = DoubleTapOverlayBinding.inflate(layoutInflater, this, true) + } +} diff --git a/app/src/main/java/com/github/libretube/views/MaterialPreferenceFragment.kt b/app/src/main/java/com/github/libretube/views/MaterialPreferenceFragment.kt new file mode 100644 index 000000000..1bce4578a --- /dev/null +++ b/app/src/main/java/com/github/libretube/views/MaterialPreferenceFragment.kt @@ -0,0 +1,47 @@ +package com.github.libretube.views + +import android.os.Bundle +import androidx.preference.ListPreference +import androidx.preference.Preference +import androidx.preference.PreferenceFragmentCompat +import com.github.libretube.R +import com.google.android.material.dialog.MaterialAlertDialogBuilder + +/** + * PreferenceFragmentCompat using the [MaterialAlertDialogBuilder] instead of the old dialog builder + */ +open class MaterialPreferenceFragment : PreferenceFragmentCompat() { + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {} + + override fun onDisplayPreferenceDialog(preference: Preference) { + when (preference) { + /** + * Show a [MaterialAlertDialogBuilder] when the preference is a [ListPreference] + */ + is ListPreference -> { + // get the index of the previous selected item + val prefIndex = preference.entryValues.indexOf(preference.value) + MaterialAlertDialogBuilder(requireContext()) + .setTitle(preference.title) + .setSingleChoiceItems(preference.entries, prefIndex) { dialog, index -> + + // get the new ListPreference value + val newValue = preference.entryValues[index].toString() + + // save the new value and call the onPreferenceChange Method + preference.value = newValue + preference.callChangeListener(newValue) + + // dismiss the dialog + dialog.dismiss() + } + .setNegativeButton(R.string.cancel, null) + .show() + } + /** + * Otherwise show the normal dialog, dialogs for other preference types are not supported yet + */ + else -> super.onDisplayPreferenceDialog(preference) + } + } +} diff --git a/app/src/main/java/com/github/libretube/views/SingleViewTouchableMotionLayout.kt b/app/src/main/java/com/github/libretube/views/SingleViewTouchableMotionLayout.kt index 9eb3060bb..a9863d535 100644 --- a/app/src/main/java/com/github/libretube/views/SingleViewTouchableMotionLayout.kt +++ b/app/src/main/java/com/github/libretube/views/SingleViewTouchableMotionLayout.kt @@ -26,7 +26,7 @@ class SingleViewTouchableMotionLayout(context: Context, attributeSet: AttributeS private val transitionListenerList = mutableListOf() init { - addTransitionListener(object : MotionLayout.TransitionListener { + addTransitionListener(object : TransitionListener { override fun onTransitionStarted( motionLayout: MotionLayout?, startId: Int, @@ -50,7 +50,7 @@ class SingleViewTouchableMotionLayout(context: Context, attributeSet: AttributeS } }) - super.setTransitionListener(object : MotionLayout.TransitionListener { + super.setTransitionListener(object : TransitionListener { override fun onTransitionStarted( motionLayout: MotionLayout?, startId: Int, diff --git a/app/src/main/res/drawable/ic_add_instance.xml b/app/src/main/res/drawable/ic_add_instance.xml index d5d34b102..334f007fa 100644 --- a/app/src/main/res/drawable/ic_add_instance.xml +++ b/app/src/main/res/drawable/ic_add_instance.xml @@ -1,7 +1,7 @@ + + diff --git a/app/src/main/res/drawable/ic_arrow_up_left.xml b/app/src/main/res/drawable/ic_arrow_up_left.xml index e5ed20662..20b455730 100644 --- a/app/src/main/res/drawable/ic_arrow_up_left.xml +++ b/app/src/main/res/drawable/ic_arrow_up_left.xml @@ -2,7 +2,7 @@ + android:tint="?attr/colorControlNormal" + android:viewportWidth="48" + android:viewportHeight="48"> + android:fillColor="#FF000000" + android:pathData="m28.58,33.834h9.207v-9.352h-2.892v6.46L28.58,30.942ZM10.261,23.518h2.892v-6.46h6.315L19.469,14.166L10.261,14.166ZM7.706,39.715q-1.302,0 -2.29,-0.988Q4.428,37.739 4.428,36.437L4.428,11.563q0,-1.35 0.988,-2.314Q6.405,8.285 7.706,8.285L40.294,8.285q1.35,0 2.314,0.964 0.964,0.964 0.964,2.314v24.874q0,1.302 -0.964,2.29 -0.964,0.988 -2.314,0.988zM7.706,36.437L40.294,36.437L40.294,11.563L7.706,11.563ZM7.706,36.437L7.706,11.563Z" /> diff --git a/app/src/main/res/drawable/ic_auth.xml b/app/src/main/res/drawable/ic_auth.xml index 0d39a3ad7..4386e435d 100644 --- a/app/src/main/res/drawable/ic_auth.xml +++ b/app/src/main/res/drawable/ic_auth.xml @@ -1,7 +1,7 @@ + + diff --git a/app/src/main/res/drawable/ic_caption_outlined.xml b/app/src/main/res/drawable/ic_caption_outlined.xml new file mode 100644 index 000000000..d264aed1f --- /dev/null +++ b/app/src/main/res/drawable/ic_caption_outlined.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_color.xml b/app/src/main/res/drawable/ic_color.xml index ae57a36aa..2dc279642 100644 --- a/app/src/main/res/drawable/ic_color.xml +++ b/app/src/main/res/drawable/ic_color.xml @@ -1,7 +1,7 @@ + + diff --git a/app/src/main/res/drawable/ic_delete.xml b/app/src/main/res/drawable/ic_delete.xml index 05fc2abd8..d78e8d21b 100644 --- a/app/src/main/res/drawable/ic_delete.xml +++ b/app/src/main/res/drawable/ic_delete.xml @@ -2,10 +2,9 @@ android:width="24dp" android:height="24dp" android:tint="?attr/colorControlNormal" - android:viewportWidth="24" - android:viewportHeight="24"> - + android:viewportWidth="48" + android:viewportHeight="48"> + android:pathData="M13.05,42q-1.2,0 -2.1,-0.9 -0.9,-0.9 -0.9,-2.1L10.05,10.5L8,10.5v-3h9.4L17.4,6h13.2v1.5L40,7.5v3h-2.05L37.95,39q0,1.2 -0.9,2.1 -0.9,0.9 -2.1,0.9ZM18.35,34.7h3L21.35,14.75h-3ZM26.65,34.7h3L29.65,14.75h-3Z" /> diff --git a/app/src/main/res/drawable/ic_discord.xml b/app/src/main/res/drawable/ic_discord.xml index 4da87697f..95f0a7a33 100644 --- a/app/src/main/res/drawable/ic_discord.xml +++ b/app/src/main/res/drawable/ic_discord.xml @@ -1,7 +1,7 @@ + android:fillColor="#FF000000" + android:pathData="M12.322,1.707 L16.615,6 12.322,10.293L12.322,7A0.292,0.292 0,0 0,12.029 6.707c-3.468,0 -6.293,2.825 -6.293,6.293 -0,3.468 2.825,6.293 6.293,6.293 3.368,0 6.107,-2.67 6.264,-6L19.707,13.293C19.55,17.416 16.191,20.707 12.029,20.707 7.767,20.707 4.322,17.262 4.322,13 4.322,8.738 7.767,5.293 12.029,5.293A0.292,0.292 0,0 0,12.322 5Z" /> diff --git a/app/src/main/res/drawable/ic_frame.xml b/app/src/main/res/drawable/ic_frame.xml index 7f0e915ba..92447508d 100644 --- a/app/src/main/res/drawable/ic_frame.xml +++ b/app/src/main/res/drawable/ic_frame.xml @@ -1,7 +1,7 @@ - + diff --git a/app/src/main/res/drawable/ic_label.xml b/app/src/main/res/drawable/ic_label.xml index 5f0035189..ca87759c5 100644 --- a/app/src/main/res/drawable/ic_label.xml +++ b/app/src/main/res/drawable/ic_label.xml @@ -1,7 +1,7 @@ + + diff --git a/app/src/main/res/drawable/ic_next.xml b/app/src/main/res/drawable/ic_next.xml new file mode 100644 index 000000000..79972afe5 --- /dev/null +++ b/app/src/main/res/drawable/ic_next.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_notification.xml b/app/src/main/res/drawable/ic_notification.xml new file mode 100644 index 000000000..7ffabd030 --- /dev/null +++ b/app/src/main/res/drawable/ic_notification.xml @@ -0,0 +1,14 @@ + + + diff --git a/app/src/main/res/drawable/ic_open.xml b/app/src/main/res/drawable/ic_open.xml new file mode 100644 index 000000000..a5f1c87be --- /dev/null +++ b/app/src/main/res/drawable/ic_open.xml @@ -0,0 +1,28 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_pause.xml b/app/src/main/res/drawable/ic_pause.xml index 645e31d2c..b92538943 100644 --- a/app/src/main/res/drawable/ic_pause.xml +++ b/app/src/main/res/drawable/ic_pause.xml @@ -4,7 +4,7 @@ android:tint="?attr/colorControlNormal" android:viewportWidth="24" android:viewportHeight="24"> - + diff --git a/app/src/main/res/drawable/ic_pause_filled.xml b/app/src/main/res/drawable/ic_pause_filled.xml index 39b09cf20..1686afa0a 100644 --- a/app/src/main/res/drawable/ic_pause_filled.xml +++ b/app/src/main/res/drawable/ic_pause_filled.xml @@ -1,7 +1,7 @@ diff --git a/app/src/main/res/drawable/ic_play.xml b/app/src/main/res/drawable/ic_play.xml index 6e5745be5..da896608b 100644 --- a/app/src/main/res/drawable/ic_play.xml +++ b/app/src/main/res/drawable/ic_play.xml @@ -1,7 +1,7 @@ + + diff --git a/app/src/main/res/drawable/ic_reddit.xml b/app/src/main/res/drawable/ic_reddit.xml index ab73945f7..2e3cd4889 100644 --- a/app/src/main/res/drawable/ic_reddit.xml +++ b/app/src/main/res/drawable/ic_reddit.xml @@ -1,7 +1,7 @@ + android:fillColor="#FF000000" + android:pathData="M11.707,1.707 L7.414,6 11.707,10.293V7A0.292,0.292 0,0 1,12 6.707c3.468,0 6.293,2.825 6.293,6.293 0,3.468 -2.825,6.293 -6.293,6.293 -3.368,0 -6.107,-2.67 -6.264,-6H4.322C4.479,17.416 7.838,20.707 12,20.707 16.262,20.707 19.707,17.262 19.707,13 19.707,8.738 16.262,5.293 12,5.293A0.292,0.292 0,0 1,11.707 5Z" /> diff --git a/app/src/main/res/drawable/ic_rotating_circle.xml b/app/src/main/res/drawable/ic_rotating_circle.xml index 63ad1c16a..d92f992c8 100644 --- a/app/src/main/res/drawable/ic_rotating_circle.xml +++ b/app/src/main/res/drawable/ic_rotating_circle.xml @@ -1,7 +1,7 @@ + + diff --git a/app/src/main/res/drawable/ic_server.xml b/app/src/main/res/drawable/ic_server.xml index e7cb848fa..9f33cc94f 100644 --- a/app/src/main/res/drawable/ic_server.xml +++ b/app/src/main/res/drawable/ic_server.xml @@ -1,7 +1,7 @@ + + diff --git a/app/src/main/res/drawable/ic_skip.xml b/app/src/main/res/drawable/ic_skip.xml index 1594f7d64..33b89f5da 100644 --- a/app/src/main/res/drawable/ic_skip.xml +++ b/app/src/main/res/drawable/ic_skip.xml @@ -1,7 +1,7 @@ + + diff --git a/app/src/main/res/drawable/ic_toggle_on.xml b/app/src/main/res/drawable/ic_toggle_on.xml new file mode 100644 index 000000000..d19f35406 --- /dev/null +++ b/app/src/main/res/drawable/ic_toggle_on.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_trending.xml b/app/src/main/res/drawable/ic_trending.xml index 536738d66..233823167 100644 --- a/app/src/main/res/drawable/ic_trending.xml +++ b/app/src/main/res/drawable/ic_trending.xml @@ -1,7 +1,7 @@ + + diff --git a/app/src/main/res/drawable/ic_weblate.xml b/app/src/main/res/drawable/ic_weblate.xml new file mode 100644 index 000000000..beaa5cb5a --- /dev/null +++ b/app/src/main/res/drawable/ic_weblate.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/rounded_bottom_sheet.xml b/app/src/main/res/drawable/rounded_bottom_sheet.xml new file mode 100644 index 000000000..a911e59af --- /dev/null +++ b/app/src/main/res/drawable/rounded_bottom_sheet.xml @@ -0,0 +1,10 @@ + + + + + + + diff --git a/app/src/main/res/layout/fragment_about.xml b/app/src/main/res/layout/activity_about.xml similarity index 87% rename from app/src/main/res/layout/fragment_about.xml rename to app/src/main/res/layout/activity_about.xml index d09cf88f7..e9c7e5914 100644 --- a/app/src/main/res/layout/fragment_about.xml +++ b/app/src/main/res/layout/activity_about.xml @@ -81,6 +81,24 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_community.xml b/app/src/main/res/layout/activity_community.xml similarity index 98% rename from app/src/main/res/layout/fragment_community.xml rename to app/src/main/res/layout/activity_community.xml index 6651fbd90..8b7715693 100644 --- a/app/src/main/res/layout/fragment_community.xml +++ b/app/src/main/res/layout/activity_community.xml @@ -6,7 +6,7 @@ - - /> + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/channel_search_row.xml b/app/src/main/res/layout/channel_row.xml similarity index 100% rename from app/src/main/res/layout/channel_search_row.xml rename to app/src/main/res/layout/channel_row.xml diff --git a/app/src/main/res/layout/channel_subscription_row.xml b/app/src/main/res/layout/channel_subscription_row.xml index 591facca0..7959fbefa 100644 --- a/app/src/main/res/layout/channel_subscription_row.xml +++ b/app/src/main/res/layout/channel_subscription_row.xml @@ -1,6 +1,7 @@ + android:textSize="16sp" + tools:text="Channel Name" /> + app:cornerRadius="20dp" + app:elevation="20dp" /> \ No newline at end of file diff --git a/app/src/main/res/layout/chapter_column.xml b/app/src/main/res/layout/chapter_column.xml index 595130de6..243941ae7 100644 --- a/app/src/main/res/layout/chapter_column.xml +++ b/app/src/main/res/layout/chapter_column.xml @@ -1,27 +1,37 @@ - + android:backgroundTint="@android:color/transparent" + app:strokeWidth="0dp"> - + - + - \ No newline at end of file + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/comments_row.xml b/app/src/main/res/layout/comments_row.xml index 5256e49d6..2a4602244 100644 --- a/app/src/main/res/layout/comments_row.xml +++ b/app/src/main/res/layout/comments_row.xml @@ -1,6 +1,7 @@ @@ -45,9 +46,9 @@ android:layout_height="wrap_content" android:ellipsize="end" android:maxLines="2" - android:text="Author and Time" android:textSize="15sp" - android:textStyle="bold" /> + android:textStyle="bold" + tools:text="Author and Time" /> + tools:text="Comment Text" /> + tools:text="LikeCount" /> - + + + + + + + + + android:layout_margin="8dp" + android:visibility="gone" />