Initial commit

This commit is contained in:
mehul4795 2021-03-27 10:55:34 +05:30
commit 6b23bf1daa
254 changed files with 12178 additions and 0 deletions

14
.gitignore vendored Normal file
View File

@ -0,0 +1,14 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
/build
/captures
.externalNativeBuild
.cxx

3
.idea/.gitignore generated vendored Normal file
View File

@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml

6
.idea/compiler.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="1.8" />
</component>
</project>

22
.idea/gradle.xml generated Normal file
View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="PLATFORM" />
<option name="distributionType" value="DEFAULT_WRAPPED" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="1.8" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
<option value="$PROJECT_DIR$/core" />
</set>
</option>
<option name="resolveModulePerSourceSet" value="false" />
<option name="useQualifiedModuleNames" value="true" />
</GradleProjectSettings>
</option>
</component>
</project>

30
.idea/jarRepositories.xml generated Normal file
View File

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RemoteRepositoriesConfiguration">
<remote-repository>
<option name="id" value="central" />
<option name="name" value="Maven Central repository" />
<option name="url" value="https://repo1.maven.org/maven2" />
</remote-repository>
<remote-repository>
<option name="id" value="jboss.community" />
<option name="name" value="JBoss Community repository" />
<option name="url" value="https://repository.jboss.org/nexus/content/repositories/public/" />
</remote-repository>
<remote-repository>
<option name="id" value="BintrayJCenter" />
<option name="name" value="BintrayJCenter" />
<option name="url" value="https://jcenter.bintray.com/" />
</remote-repository>
<remote-repository>
<option name="id" value="Google" />
<option name="name" value="Google" />
<option name="url" value="https://dl.google.com/dl/android/maven2/" />
</remote-repository>
<remote-repository>
<option name="id" value="MavenRepo" />
<option name="name" value="MavenRepo" />
<option name="url" value="https://repo.maven.apache.org/maven2/" />
</remote-repository>
</component>
</project>

9
.idea/misc.xml generated Normal file
View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_8" default="true" project-jdk-name="1.8" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>

1
app/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

141
app/build.gradle Normal file
View File

@ -0,0 +1,141 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-kapt'
apply plugin: 'androidx.navigation.safeargs.kotlin'
apply plugin: 'com.google.gms.google-services'
android {
compileSdkVersion COMPILE_SDK_VERSION
defaultConfig {
applicationId "aculix.channelify.app"
minSdkVersion 21
targetSdkVersion TARGET_SDK_VERSION
versionCode 7
versionName "1.32"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
debug {
resValue "string", "youtube_api_key", YT_API_KEY
}
release {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
resValue "string", "youtube_api_key", YT_API_KEY
}
}
kotlinOptions {
jvmTarget = "1.8"
}
viewBinding {
enabled = true
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
packagingOptions {
exclude 'META-INF/core_release.kotlin_module'
}
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
// Kotlin
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion"
// Kotlin Coroutines
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-play-services:$coroutinesVersion"
// Koin
implementation "org.koin:koin-android:$koinVersion"
implementation "org.koin:koin-androidx-viewmodel:$koinVersion"
// AndroidX
implementation 'androidx.appcompat:appcompat:1.1.0'
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
implementation 'androidx.core:core-ktx:1.3.0-rc01'
implementation "androidx.constraintlayout:constraintlayout:$clVersion"
implementation "androidx.recyclerview:recyclerview:1.1.0"
// Architecture Components
implementation "androidx.lifecycle:lifecycle-extensions:$archVersion"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$archVersion"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$archVersion"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$archVersion"
// Room
implementation "androidx.room:room-runtime:$roomVersion"
implementation "androidx.room:room-ktx:$roomVersion"
kapt "androidx.room:room-compiler:$roomVersion"
// Material theme
implementation "com.google.android.material:material:$materialThemeVersion"
// Navigation
implementation "androidx.navigation:navigation-fragment-ktx:$navVersion"
implementation "androidx.navigation:navigation-ui-ktx:$navVersion"
// Retrofit, OkHttp & Gson
implementation "com.squareup.retrofit2:retrofit:2.8.1"
implementation 'com.squareup.retrofit2:converter-gson:2.8.1'
implementation 'com.squareup.okhttp3:logging-interceptor:4.2.2'
// Timber
implementation "com.jakewharton.timber:timber:$timberVersion"
// Fast Adapter
implementation "com.mikepenz:fastadapter:$fastAdapterVersion"
implementation "com.mikepenz:fastadapter-extensions-diff:$fastAdapterVersion"
implementation "com.mikepenz:fastadapter-extensions-utils:$fastAdapterVersion"
implementation "com.mikepenz:fastadapter-extensions-ui:$fastAdapterVersion"
implementation "com.mikepenz:fastadapter-extensions-paged:$fastAdapterVersion"
implementation "com.mikepenz:itemanimators:1.1.0"
// Coil
implementation "io.coil-kt:coil:0.10.0"
// Paging
implementation "androidx.paging:paging-runtime:2.1.2"
// TimeAgo
implementation 'com.github.marlonlom:timeago:4.0.1'
// YouTube Player
implementation 'com.pierfrancescosoffritti.androidyoutubeplayer:core:10.0.5'
// Circle ImageView
implementation 'de.hdodenhof:circleimageview:3.1.0'
// Material Dialog
implementation "com.afollestad.material-dialogs:core:$materialDialogVersion"
implementation "com.afollestad.material-dialogs:bottomsheets:$materialDialogVersion"
// Kotpref
implementation 'com.chibatching.kotpref:kotpref:2.10.0'
// Firebase
implementation 'com.google.firebase:firebase-analytics:17.4.0'
implementation 'com.google.firebase:firebase-messaging:20.1.6'
implementation 'com.google.android.gms:play-services-ads:19.1.0'
// Custom Tabs
implementation "saschpe.android:customtabs:$saschpeCustomTabs"
implementation "androidx.browser:browser:$browserVersion"
// Modules
implementation project(':core')
}

56
app/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1,56 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
##-------------------------- Model classes --------------------------
-keep class aculix.channelify.app.model.** { *; }
#-------------------------- Coroutines --------------------------#
# ServiceLoader support
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
-keepnames class kotlinx.coroutines.android.AndroidExceptionPreHandler {}
-keepnames class kotlinx.coroutines.android.AndroidDispatcherFactory {}
# Most of volatile fields are updated with AFU and should not be mangled
-keepclassmembernames class kotlinx.** {
volatile <fields>;
}
#-------------------------- OkHttp --------------------------#
# JSR 305 annotations are for embedding nullability information.
-dontwarn javax.annotation.**
# A resource is loaded with a relative path so the package of this class must be preserved.
-keepnames class okhttp3.internal.publicsuffix.PublicSuffixDatabase
# Animal Sniffer compileOnly dependency to ensure APIs are compatible with older versions of Java.
-dontwarn org.codehaus.mojo.animal_sniffer.*
# OkHttp platform used only on JVM and when Conscrypt dependency is available.
-dontwarn okhttp3.internal.platform.ConscryptPlatform
# Animal Sniffer compileOnly dependency to ensure APIs are compatible with older versions of Java.
-dontwarn org.codehaus.mojo.animal_sniffer.*

1
app/release/output.json Normal file
View File

@ -0,0 +1 @@
[{"outputType":{"type":"APK"},"apkData":{"type":"MAIN","splits":[],"versionCode":7,"versionName":"1.32","enabled":true,"outputFile":"app-release.apk","fullName":"release","baseName":"release","dirName":""},"path":"app-release.apk","properties":{}}]

View File

@ -0,0 +1,58 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="aculix.channelify.app">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".Channelify"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme"
tools:ignore="GoogleAppIndexingWarning">
<activity android:name=".activity.SplashActivity"
android:screenOrientation="portrait"
android:theme="@style/SplashTheme">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".activity.MainActivity"
android:screenOrientation="portrait"
android:windowSoftInputMode="adjustPan"/>
<activity
android:name=".activity.VideoPlayerActivity"
android:configChanges="orientation|screenSize|keyboardHidden|smallestScreenSize|screenLayout"
android:screenOrientation="portrait"
/>
<meta-data
android:name="preloaded_fonts"
android:resource="@array/preloaded_fonts" />
<!-- FCM -->
<meta-data
android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@drawable/ic_stat_notification" />
<meta-data
android:name="com.google.firebase.messaging.default_notification_color"
android:resource="@color/colorPrimary" />
<!-- Admob -->
<meta-data
android:name="com.google.android.gms.ads.APPLICATION_ID"
android:value="@string/admob_app_id"/>
</application>
</manifest>

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -0,0 +1,89 @@
package aculix.channelify.app
import aculix.channelify.app.di.*
import aculix.channelify.app.sharedpref.AppPref
import android.app.Application
import androidx.appcompat.app.AppCompatDelegate
import com.chibatching.kotpref.Kotpref
import com.google.android.gms.ads.MobileAds
import com.google.android.gms.ads.RequestConfiguration
import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger
import org.koin.core.context.startKoin
import saschpe.android.customtabs.CustomTabsActivityLifecycleCallbacks
import timber.log.Timber
import java.util.*
class Channelify : Application() {
companion object {
var isAdEnabled = true
}
override fun onCreate() {
super.onCreate()
isAdEnabled = resources.getBoolean(R.bool.enable_ads)
initializeKotpref()
setThemeMode()
initializeTimber()
initializeKoin()
initializeCustomTabs()
if (resources.getBoolean(R.bool.enable_ads)) initializeAdmob()
}
private fun setThemeMode() {
if (AppPref.isLightThemeEnabled) {
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
} else {
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
}
}
private fun initializeTimber() {
if (BuildConfig.DEBUG) {
Timber.plant(Timber.DebugTree())
}
}
private fun initializeKotpref() {
Kotpref.init(this)
}
private fun initializeKoin() {
startKoin {
androidLogger()
androidContext(this@Channelify)
modules(
listOf(
appModule,
homeModule,
videoPlayerModule,
commentsModule,
videoDetailsModule,
commentRepliesModule,
playlistsModule,
playlistVideosModule,
favoritesModule,
searchModule,
aboutModule
)
)
}
}
private fun initializeAdmob() {
MobileAds.initialize(this) {
val testDeviceIds = listOf("7BD04413716C0B3DD5C73F814E02D21A")
val configuration =
RequestConfiguration.Builder().setTestDeviceIds(testDeviceIds).build()
MobileAds.setRequestConfiguration(configuration)
}
}
private fun initializeCustomTabs() {
registerActivityLifecycleCallbacks(CustomTabsActivityLifecycleCallbacks())
}
}

View File

@ -0,0 +1,61 @@
package aculix.channelify.app.activity
import aculix.channelify.app.Channelify
import aculix.channelify.app.R
import aculix.channelify.app.utils.getAdaptiveBannerAdSize
import aculix.core.extensions.makeGone
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.util.DisplayMetrics
import androidx.navigation.findNavController
import androidx.navigation.ui.setupWithNavController
import com.google.android.gms.ads.*
import kotlinx.android.synthetic.main.activity_main.*
import java.util.*
class MainActivity : AppCompatActivity() {
private lateinit var adView: AdView
private var initialLayoutComplete = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val navController = findNavController(R.id.navHostFragment)
bottomNavView.setupWithNavController(navController)
if (Channelify.isAdEnabled) setupAd() else adViewContainerMain.makeGone()
}
override fun onPause() {
if (Channelify.isAdEnabled) adView.pause()
super.onPause()
}
override fun onResume() {
if (Channelify.isAdEnabled) adView.resume()
super.onResume()
}
override fun onDestroy() {
if (Channelify.isAdEnabled) adView.destroy()
super.onDestroy()
}
private fun setupAd() {
adView = AdView(this)
adViewContainerMain.addView(adView)
adViewContainerMain.viewTreeObserver.addOnGlobalLayoutListener {
if (!initialLayoutComplete) {
initialLayoutComplete = true
adView.adUnitId = getString(R.string.main_banner_ad_id)
adView.adSize = getAdaptiveBannerAdSize(adViewContainerMain)
adView.loadAd(AdRequest.Builder().build())
}
}
}
}

View File

@ -0,0 +1,16 @@
package aculix.channelify.app.activity
import android.content.Intent
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
class SplashActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val intent = Intent(this, MainActivity::class.java)
startActivity(intent)
finish()
}
}

View File

@ -0,0 +1,132 @@
package aculix.channelify.app.activity
import aculix.channelify.app.R
import aculix.channelify.app.fragment.VideoDetailsFragment
import aculix.channelify.app.utils.FullScreenHelper
import aculix.channelify.app.viewmodel.VideoPlayerViewModel
import aculix.core.extensions.toast
import android.annotation.SuppressLint
import android.app.PictureInPictureParams
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.ActivityInfo
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.os.Build
import android.os.Bundle
import android.util.Rational
import android.view.ContextThemeWrapper
import android.view.View
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.AppCompatImageView
import androidx.appcompat.widget.AppCompatToggleButton
import androidx.core.content.ContextCompat
import androidx.core.os.bundleOf
import androidx.navigation.findNavController
import com.google.android.gms.ads.AdListener
import com.google.android.gms.ads.AdRequest
import com.google.android.gms.ads.InterstitialAd
import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.YouTubePlayer
import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.listeners.AbstractYouTubePlayerListener
import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.listeners.YouTubePlayerFullScreenListener
import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.utils.loadOrCueVideo
import kotlinx.android.synthetic.main.activity_video_player.*
import org.koin.androidx.viewmodel.ext.android.viewModel
class VideoPlayerActivity : AppCompatActivity(R.layout.activity_video_player) {
companion object {
const val VIDEO_ID = "video_id"
fun startActivity(context: Context?, videoId: String) {
val intent = Intent(context, VideoPlayerActivity::class.java).apply {
putExtra(VIDEO_ID, videoId)
}
context?.startActivity(intent)
}
}
private val viewModel by viewModel<VideoPlayerViewModel>() // Lazy inject ViewModel
lateinit var fullScreenHelper: FullScreenHelper
lateinit var videoId: String
private var videoElapsedTimeInSeconds = 0f
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
fullScreenHelper = FullScreenHelper(this)
videoId = intent.getStringExtra(VIDEO_ID)!!
// Passing the videoId as argument to the start destination
findNavController(R.id.navHostVideoPlayer).setGraph(
R.navigation.video_player_graph,
bundleOf(VideoDetailsFragment.VIDEO_ID to videoId)
)
initYouTubePlayer()
}
override fun onBackPressed() {
if (ytVideoPlayerView.isFullScreen()) ytVideoPlayerView.exitFullScreen() else super.onBackPressed()
}
private fun initYouTubePlayer() {
lifecycle.addObserver(ytVideoPlayerView)
ytVideoPlayerView.addYouTubePlayerListener(object : AbstractYouTubePlayerListener() {
override fun onReady(youTubePlayer: YouTubePlayer) {
youTubePlayer.loadOrCueVideo(lifecycle, videoId, 0f)
addFullScreenListenerToPlayer()
setupCustomActions(youTubePlayer)
}
override fun onCurrentSecond(youTubePlayer: YouTubePlayer, second: Float) {
videoElapsedTimeInSeconds = second
}
})
}
/**
* Adds the forward and rewind action button to the Player
*/
private fun setupCustomActions(youTubePlayer: YouTubePlayer) {
ytVideoPlayerView.getPlayerUiController()
.setCustomAction1(
ContextCompat.getDrawable(this, R.drawable.ic_rewind)!!,
View.OnClickListener {
videoElapsedTimeInSeconds -= 10
youTubePlayer.seekTo(videoElapsedTimeInSeconds)
})
.setCustomAction2(
ContextCompat.getDrawable(this, R.drawable.ic_forward)!!,
View.OnClickListener {
videoElapsedTimeInSeconds += 10
youTubePlayer.seekTo(videoElapsedTimeInSeconds)
})
}
/**
* Changes the orientation of the activity based on the
* change of the player state (Full screen or not)
*/
@SuppressLint("SourceLockedOrientationActivity")
private fun addFullScreenListenerToPlayer() {
ytVideoPlayerView.addFullScreenListener(object : YouTubePlayerFullScreenListener {
override fun onYouTubePlayerEnterFullScreen() {
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
fullScreenHelper.enterFullScreen()
}
override fun onYouTubePlayerExitFullScreen() {
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
fullScreenHelper.exitFullScreen()
}
})
}
}

View File

@ -0,0 +1,17 @@
package aculix.channelify.app.api
import aculix.channelify.app.model.ChannelInfo
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Query
interface ChannelInfoService {
@GET("channels")
suspend fun getChannelInfo(
@Query("id") channelId: String,
@Query("part") part: String = "snippet, statistics, brandingSettings",
@Query("fields") fields: String = "items(snippet(title, description, publishedAt, thumbnails), statistics(viewCount, subscriberCount, videoCount), brandingSettings(image(bannerMobileHdImageUrl, bannerMobileMediumHdImageUrl)))",
@Query("maxResults") maxResults: Int = 1
): Response<ChannelInfo>
}

View File

@ -0,0 +1,16 @@
package aculix.channelify.app.api
import aculix.channelify.app.model.ChannelUploadsPlaylistInfo
import aculix.channelify.app.model.SearchedVideo
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Query
interface ChannelsService {
@GET("channels")
suspend fun getChannelUploadsPlaylistInfo(
@Query("id") channelId: String,
@Query("part") part: String = "contentDetails"
): Response<ChannelUploadsPlaylistInfo>
}

View File

@ -0,0 +1,22 @@
package aculix.channelify.app.api
import aculix.channelify.app.model.CommentReply
import aculix.channelify.app.utils.Constants
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Query
interface CommentRepliesService {
/**
* Gets the list of replies for a particular comment
*/
@GET("comments")
suspend fun getCommentReplies(
@Query("parentId") commentId: String,
@Query("pageToken") pageToken: String?,
@Query("part") part: String = "snippet",
@Query("fields") fields: String = "nextPageToken, items(snippet(authorDisplayName, authorProfileImageUrl, textOriginal, likeCount, publishedAt, updatedAt))",
@Query("maxResults") maxResults: Int = Constants.YT_API_MAX_RESULTS
): Response<CommentReply>
}

View File

@ -0,0 +1,25 @@
package aculix.channelify.app.api
import aculix.channelify.app.model.Comment
import aculix.channelify.app.model.CommentReply
import aculix.channelify.app.model.PlaylistItemInfo
import aculix.channelify.app.utils.Constants
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Query
interface CommentService {
/**
* Gets the list of comments of a particular video
*/
@GET("commentThreads")
suspend fun getVideoComments(
@Query("videoId") videoId: String,
@Query("pageToken") pageToken: String?,
@Query("order") sortOrder: String,
@Query("part") part: String = "snippet",
@Query("fields") fields: String = "nextPageToken, items(snippet(topLevelComment(id, snippet(authorDisplayName, authorProfileImageUrl, textOriginal, likeCount, publishedAt, updatedAt)), totalReplyCount))",
@Query("maxResults") maxResults: Int = Constants.YT_API_MAX_RESULTS
): Response<Comment>
}

View File

@ -0,0 +1,20 @@
package aculix.channelify.app.api
import aculix.channelify.app.model.ChannelUploadsPlaylistInfo
import aculix.channelify.app.model.PlaylistItemInfo
import aculix.channelify.app.utils.Constants
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Query
interface PlaylistItemsService {
@GET("playlistItems")
suspend fun getPlaylistVideos(
@Query("playlistId") playlistId: String,
@Query("pageToken") pageToken: String?,
@Query("part") part: String = "snippet,contentDetails",
@Query("fields") fields: String = "nextPageToken, prevPageToken, items(snippet(title, thumbnails), contentDetails(videoId, videoPublishedAt))",
@Query("maxResults") maxResults: Int = Constants.YT_API_MAX_RESULTS
): Response<PlaylistItemInfo>
}

View File

@ -0,0 +1,19 @@
package aculix.channelify.app.api
import aculix.channelify.app.model.Playlist
import aculix.channelify.app.utils.Constants
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Query
interface PlaylistsService {
@GET("playlists")
suspend fun getPlaylists(
@Query("channelId") channelId: String,
@Query("pageToken") pageToken: String?,
@Query("part") part: String = "snippet,contentDetails",
@Query("fields") fields: String = "nextPageToken, items(id, snippet(publishedAt, title, description, thumbnails), contentDetails)",
@Query("maxResults") maxResults: Int = Constants.YT_API_MAX_RESULTS
): Response<Playlist>
}

View File

@ -0,0 +1,20 @@
package aculix.channelify.app.api
import aculix.channelify.app.model.SearchedVideo
import aculix.channelify.app.utils.Constants
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Query
import retrofit2.http.QueryMap
interface SearchVideoService {
@GET("search")
suspend fun searchVideos(
@Query("q") searchQuery: String,
@Query("channelId") channelId: String,
@Query("pageToken") pageToken: String?,
@QueryMap defaultQueryMap: HashMap<String, String>,
@Query("maxResults") maxResults: Int = Constants.YT_API_MAX_RESULTS
): Response<SearchedVideo>
}

View File

@ -0,0 +1,17 @@
package aculix.channelify.app.api
import aculix.channelify.app.model.Video
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Query
interface VideosService {
@GET("videos")
suspend fun getVideoInfo(
@Query("id") videoId: String,
@Query("part") part: String = "snippet, statistics",
@Query("fields") fields: String = "items(snippet(publishedAt, title, description, thumbnails), statistics)",
@Query("maxResults") maxResults: Int = 1
): Response<Video>
}

View File

@ -0,0 +1,11 @@
package aculix.channelify.app.db
import aculix.channelify.app.model.FavoriteVideo
import androidx.room.Database
import androidx.room.RoomDatabase
@Database(entities = [FavoriteVideo::class], version = 1)
abstract class ChannelifyDatabase : RoomDatabase() {
abstract fun favoriteVideoDao(): FavoriteVideoDao
}

View File

@ -0,0 +1,33 @@
package aculix.channelify.app.db
import aculix.channelify.app.model.FavoriteVideo
import androidx.lifecycle.LiveData
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
@Dao
interface FavoriteVideoDao {
@Insert
suspend fun addFavoriteVideo(favoriteVideo: FavoriteVideo)
@Delete
suspend fun removeFavoriteVideo(favoriteVideo: FavoriteVideo)
@Query("DELETE FROM favorite_videos where id in (:idList)")
suspend fun removeMultipleFavoriteVideos(idList: List<String>)
@Query("SELECT id FROM favorite_videos WHERE id = :id LIMIT 1")
suspend fun getFavoriteVideoId(id: String): String?
@Query("SELECT * FROM favorite_videos ORDER BY timeInMillis DESC")
suspend fun getAllFavoriteVideos(): List<FavoriteVideo>
@Query("DELETE FROM favorite_videos")
suspend fun removeAllFavoriteVideos()
}

View File

@ -0,0 +1,22 @@
package aculix.channelify.app.di
import aculix.channelify.app.R
import aculix.channelify.app.api.ChannelInfoService
import aculix.channelify.app.repository.AboutRepository
import aculix.channelify.app.viewmodel.AboutViewModel
import org.koin.android.ext.koin.androidContext
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module
import retrofit2.Retrofit
val aboutModule = module {
factory { provideChannelInfoService(get()) }
single { AboutRepository(get()) }
viewModel { AboutViewModel(get(), androidContext().getString(R.string.channel_id), androidContext()) }
}
private fun provideChannelInfoService(retrofit: Retrofit) =
retrofit.create(ChannelInfoService::class.java)

View File

@ -0,0 +1,120 @@
package aculix.channelify.app.di
import aculix.channelify.app.BuildConfig
import aculix.channelify.app.R
import aculix.channelify.app.db.ChannelifyDatabase
import android.annotation.SuppressLint
import android.app.Application
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.room.Room
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Response
import org.koin.android.ext.koin.androidApplication
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import timber.log.Timber
import java.security.MessageDigest
val appModule = module {
single { provideApplicationSignature(androidContext()) }
single { provideOkHttpClient(androidContext(), get()) }
single { provideRetrofit(get()) }
single { provideRoomDatabase(androidApplication()) }
}
@SuppressLint("PackageManagerGetSignatures")
fun provideApplicationSignature(context: Context): List<String> {
val signatureList: List<String>
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
val signature = context.packageManager.getPackageInfo(
BuildConfig.APPLICATION_ID,
PackageManager.GET_SIGNING_CERTIFICATES
).signingInfo
signatureList = if (signature.hasMultipleSigners()) {
signature.apkContentsSigners.map {
val digest = MessageDigest.getInstance("SHA")
digest.update(it.toByteArray())
bytesToHex(digest.digest())
}
} else {
signature.signingCertificateHistory.map {
val digest = MessageDigest.getInstance("SHA")
digest.update(it.toByteArray())
bytesToHex(digest.digest())
}
}
} else {
val signature = context.packageManager.getPackageInfo(
BuildConfig.APPLICATION_ID,
PackageManager.GET_SIGNATURES
).signatures
signatureList = signature.map {
val digest = MessageDigest.getInstance("SHA")
digest.update(it.toByteArray())
bytesToHex(digest.digest())
}
}
return signatureList
} catch (e: Exception) {
Timber.e(e.stackTrace.toString())
}
return emptyList()
}
fun bytesToHex(bytes: ByteArray): String {
val hexArray =
charArrayOf('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F')
val hexChars = CharArray(bytes.size * 2)
var v: Int
for (j in bytes.indices) {
v = bytes[j].toInt() and 0xFF
hexChars[j * 2] = hexArray[v.ushr(4)]
hexChars[j * 2 + 1] = hexArray[v and 0x0F]
}
return String(hexChars)
}
private fun provideOkHttpClient(
androidContext: Context,
signatureList: List<String>
): OkHttpClient {
val httpClient = OkHttpClient.Builder()
httpClient.addInterceptor(object : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val original = chain.request()
val originalHttpUrl = original.url
val url = originalHttpUrl.newBuilder()
.addQueryParameter("key", androidContext.getString(R.string.youtube_api_key))
.build()
val requestBuilder = original.newBuilder().url(url)
.addHeader("X-Android-Package", BuildConfig.APPLICATION_ID)
.addHeader("X-Android-Cert", signatureList[0])
val request = requestBuilder.build()
return chain.proceed(request)
}
})
return httpClient.build()
}
private fun provideRetrofit(httpClient: OkHttpClient) = Retrofit.Builder()
.baseUrl("https://www.googleapis.com/youtube/v3/")
.client(httpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
private fun provideRoomDatabase(androidApplication: Application) =
Room.databaseBuilder(androidApplication, ChannelifyDatabase::class.java, "channelify-db")
.build()

View File

@ -0,0 +1,24 @@
package aculix.channelify.app.di
import aculix.channelify.app.api.CommentRepliesService
import aculix.channelify.app.api.CommentService
import aculix.channelify.app.repository.CommentRepliesRepository
import aculix.channelify.app.repository.CommentsRepository
import aculix.channelify.app.viewmodel.CommentRepliesViewModel
import aculix.channelify.app.viewmodel.CommentsViewModel
import org.koin.android.ext.koin.androidContext
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module
import retrofit2.Retrofit
val commentRepliesModule = module {
factory { provideCommentRepliesService(get()) }
single { CommentRepliesRepository(get()) }
viewModel { CommentRepliesViewModel(get()) }
}
private fun provideCommentRepliesService(retrofit: Retrofit) =
retrofit.create(CommentRepliesService::class.java)

View File

@ -0,0 +1,21 @@
package aculix.channelify.app.di
import aculix.channelify.app.api.CommentService
import aculix.channelify.app.repository.CommentsRepository
import aculix.channelify.app.viewmodel.CommentsViewModel
import org.koin.android.ext.koin.androidContext
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module
import retrofit2.Retrofit
val commentsModule = module {
factory { provideCommentService(get()) }
single { CommentsRepository(get()) }
viewModel { CommentsViewModel(get(), androidContext()) }
}
private fun provideCommentService(retrofit: Retrofit) =
retrofit.create(CommentService::class.java)

View File

@ -0,0 +1,15 @@
package aculix.channelify.app.di
import aculix.channelify.app.repository.FavoritesRepository
import aculix.channelify.app.viewmodel.FavoritesViewModel
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module
val favoritesModule = module {
// FavoriteVideoDao is already injected in videoDetailsModule
single { FavoritesRepository(get()) }
viewModel { FavoritesViewModel(get()) }
}

View File

@ -0,0 +1,28 @@
package aculix.channelify.app.di
import aculix.channelify.app.R
import aculix.channelify.app.api.ChannelsService
import aculix.channelify.app.api.PlaylistItemsService
import aculix.channelify.app.api.SearchVideoService
import aculix.channelify.app.repository.HomeRepository
import aculix.channelify.app.viewmodel.HomeViewModel
import org.koin.android.ext.koin.androidContext
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module
import retrofit2.Retrofit
val homeModule = module {
factory { provideChannelsService(get()) }
factory { providePlaylistItemsService(get()) }
single { HomeRepository(get(), get(), get()) }
viewModel { HomeViewModel(get(), androidContext().getString(R.string.channel_id), androidContext()) }
}
private fun provideChannelsService(retrofit: Retrofit) =
retrofit.create(ChannelsService::class.java)
private fun providePlaylistItemsService(retrofit: Retrofit) =
retrofit.create(PlaylistItemsService::class.java)

View File

@ -0,0 +1,17 @@
package aculix.channelify.app.di
import aculix.channelify.app.repository.PlaylistVideosRepository
import aculix.channelify.app.viewmodel.PlaylistVideosViewModel
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module
/**
* No need to provide PlaylistItemsService since it is already being provided by Koin in the
* homeModule
*/
val playlistVideosModule = module {
single { PlaylistVideosRepository(get()) }
viewModel { PlaylistVideosViewModel(get()) }
}

View File

@ -0,0 +1,23 @@
package aculix.channelify.app.di
import aculix.channelify.app.R
import aculix.channelify.app.api.PlaylistsService
import aculix.channelify.app.api.SearchVideoService
import aculix.channelify.app.repository.PlaylistsRepository
import aculix.channelify.app.viewmodel.PlaylistsViewModel
import org.koin.android.ext.koin.androidContext
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module
import retrofit2.Retrofit
val playlistsModule = module {
factory { providePlaylistsService(get()) }
single { PlaylistsRepository(get()) }
viewModel { PlaylistsViewModel(get(), androidContext().getString(R.string.channel_id)) }
}
private fun providePlaylistsService(retrofit: Retrofit) =
retrofit.create(PlaylistsService::class.java)

View File

@ -0,0 +1,22 @@
package aculix.channelify.app.di
import aculix.channelify.app.R
import aculix.channelify.app.api.SearchVideoService
import aculix.channelify.app.repository.SearchRepository
import aculix.channelify.app.viewmodel.SearchViewModel
import org.koin.android.ext.koin.androidContext
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module
import retrofit2.Retrofit
val searchModule = module {
factory { provideSearchVideoService(get()) }
single { SearchRepository(get()) }
viewModel { SearchViewModel(get(), androidContext().getString(R.string.channel_id), androidContext().getString(R.string.error_search_empty_result_title)) }
}
private fun provideSearchVideoService(retrofit: Retrofit) =
retrofit.create(SearchVideoService::class.java)

View File

@ -0,0 +1,29 @@
package aculix.channelify.app.di
import aculix.channelify.app.api.VideosService
import aculix.channelify.app.db.ChannelifyDatabase
import aculix.channelify.app.repository.VideoDetailsRepository
import aculix.channelify.app.repository.VideoPlayerRepository
import aculix.channelify.app.viewmodel.VideoDetailsViewModel
import aculix.channelify.app.viewmodel.VideoPlayerViewModel
import org.koin.android.ext.koin.androidContext
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module
import retrofit2.Retrofit
val videoDetailsModule = module {
factory { provideVideosService(get()) }
single { provideFavoriteVideoDao(get()) }
single { VideoDetailsRepository(get(), get()) }
viewModel { VideoDetailsViewModel(get(), androidContext()) }
}
private fun provideVideosService(retrofit: Retrofit) =
retrofit.create(VideosService::class.java)
private fun provideFavoriteVideoDao(channelifyDatabase: ChannelifyDatabase) =
channelifyDatabase.favoriteVideoDao()

View File

@ -0,0 +1,13 @@
package aculix.channelify.app.di
import aculix.channelify.app.api.VideosService
import aculix.channelify.app.repository.VideoPlayerRepository
import aculix.channelify.app.viewmodel.VideoPlayerViewModel
import org.koin.android.ext.koin.androidContext
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module
import retrofit2.Retrofit
val videoPlayerModule = module {
}

View File

@ -0,0 +1,90 @@
package aculix.channelify.app.fastadapteritems
import aculix.channelify.app.R
import aculix.channelify.app.model.Comment
import aculix.channelify.app.utils.DateTimeUtils
import aculix.core.extensions.makeGone
import aculix.core.extensions.makeVisible
import android.view.View
import coil.api.load
import com.google.android.material.button.MaterialButton
import com.google.android.material.textview.MaterialTextView
import com.mikepenz.fastadapter.FastAdapter
import com.mikepenz.fastadapter.items.AbstractItem
import de.hdodenhof.circleimageview.CircleImageView
class CommentItem(val comment: Comment.Item?) : AbstractItem<CommentItem.CommentViewHolder>() {
override val layoutRes: Int
get() = R.layout.item_comment
override val type: Int
get() = R.id.fastadapter_comment_item_id
override fun getViewHolder(v: View): CommentViewHolder {
return CommentViewHolder(v)
}
class CommentViewHolder(private var view: View) : FastAdapter.ViewHolder<CommentItem>(view) {
private val profilePhoto: CircleImageView = view.findViewById(R.id.ivProfileCommentItem)
private val commenterNameTime: MaterialTextView =
view.findViewById(R.id.tvNameTimeCommentItem)
private val commentContent: MaterialTextView = view.findViewById(R.id.tvContentCommentItem)
private val likeCount: MaterialTextView = view.findViewById(R.id.tvLikeCountCommentItem)
private val repliesCount: MaterialTextView =
view.findViewById(R.id.tvCommentCountCommentItem)
private val viewReplies: MaterialButton = view.findViewById(R.id.btnViewRepliesCommentItem)
override fun bindView(item: CommentItem, payloads: List<Any>) {
item.comment?.snippet?.let {
// Profile photo
profilePhoto.load(it.topLevelComment.snippet.authorProfileImageUrl)
// Commenter name and published time
if(it.topLevelComment.snippet.publishedAt == it.topLevelComment.snippet.updatedAt) {
// Comment not edited
commenterNameTime.text = view.context.getString(
R.string.text_commenter_name_time,
it.topLevelComment.snippet.authorDisplayName,
DateTimeUtils.getTimeAgo(it.topLevelComment.snippet.publishedAt)
)
} else {
// Edited comment
commenterNameTime.text = view.context.getString(
R.string.text_commenter_name_time_edited,
it.topLevelComment.snippet.authorDisplayName,
DateTimeUtils.getTimeAgo(it.topLevelComment.snippet.updatedAt)
)
}
// Comments count
commentContent.text = it.topLevelComment.snippet.textOriginal
// Comments Like count
likeCount.text = it.topLevelComment.snippet.likeCount.toString()
// Comments Reply count
repliesCount.text = it.totalReplyCount.toString()
// View Replies button
if (it.totalReplyCount > 0) {
viewReplies.makeVisible()
viewReplies.text = view.context.resources.getQuantityString(R.plurals.btn_view_replies, it.totalReplyCount, it.totalReplyCount)
} else {
viewReplies.makeGone()
}
}
}
override fun unbindView(item: CommentItem) {
profilePhoto.setImageDrawable(null)
commenterNameTime.text = null
commentContent.text = null
likeCount.text = null
repliesCount.text = null
}
}
}

View File

@ -0,0 +1,77 @@
package aculix.channelify.app.fastadapteritems
import aculix.channelify.app.R
import aculix.channelify.app.model.CommentReply
import aculix.channelify.app.utils.DateTimeUtils
import android.view.View
import coil.api.load
import com.google.android.material.textview.MaterialTextView
import com.mikepenz.fastadapter.FastAdapter
import com.mikepenz.fastadapter.items.AbstractItem
import de.hdodenhof.circleimageview.CircleImageView
class CommentReplyItem(val commentReply: CommentReply.Item?) :
AbstractItem<CommentReplyItem.CommentReplyViewHolder>() {
override val layoutRes: Int
get() = R.layout.item_comment_reply
override val type: Int
get() = R.id.fastadapter_comment_reply_item_id
override fun getViewHolder(v: View): CommentReplyViewHolder {
return CommentReplyViewHolder(v)
}
class CommentReplyViewHolder(private var view: View) :
FastAdapter.ViewHolder<CommentReplyItem>(view) {
private val profilePhoto: CircleImageView =
view.findViewById(R.id.ivProfileCommentReplyItem)
private val commenterNameTime: MaterialTextView =
view.findViewById(R.id.tvNameTimeCommentReplyItem)
private val commentContent: MaterialTextView =
view.findViewById(R.id.tvContentCommentReplyItem)
private val likeCount: MaterialTextView =
view.findViewById(R.id.tvLikeCountCommentReplyItem)
override fun bindView(item: CommentReplyItem, payloads: List<Any>) {
item.commentReply?.snippet?.let {
// Profile photo
profilePhoto.load(it.authorProfileImageUrl)
// Commenter name and published time
if (it.publishedAt == it.updatedAt) {
// Comment not edited
commenterNameTime.text = view.context.getString(
R.string.text_commenter_name_time,
it.authorDisplayName,
DateTimeUtils.getTimeAgo(it.publishedAt)
)
} else {
// Edited comment
commenterNameTime.text = view.context.getString(
R.string.text_commenter_name_time_edited,
it.authorDisplayName,
DateTimeUtils.getTimeAgo(it.updatedAt)
)
}
// Comment Content
commentContent.text = it.textOriginal
// Comments Like count
likeCount.text = it.likeCount.toString()
}
}
override fun unbindView(item: CommentReplyItem) {
profilePhoto.setImageDrawable(null)
commenterNameTime.text = null
commentContent.text = null
likeCount.text = null
}
}
}

View File

@ -0,0 +1,57 @@
package aculix.channelify.app.fastadapteritems
import aculix.channelify.app.R
import aculix.channelify.app.model.FavoriteVideo
import aculix.core.extensions.to64BitHash
import android.view.View
import androidx.appcompat.widget.AppCompatImageView
import androidx.appcompat.widget.AppCompatTextView
import androidx.core.content.ContextCompat
import coil.api.load
import com.mikepenz.fastadapter.FastAdapter
import com.mikepenz.fastadapter.items.AbstractItem
class FavoriteItem(val favoriteVideo: FavoriteVideo) :
AbstractItem<FavoriteItem.FavoriteViewHolder>() {
override val layoutRes: Int
get() = R.layout.item_favorite
override val type: Int
get() = R.id.fastadapter_favorite_item_id
override fun getViewHolder(v: View): FavoriteViewHolder {
return FavoriteViewHolder(v)
}
class FavoriteViewHolder(private var view: View) : FastAdapter.ViewHolder<FavoriteItem>(view) {
private val thumbnail: AppCompatImageView = view.findViewById(R.id.ivThumbnailFavoriteItem)
private val favoriteIcon: AppCompatImageView = view.findViewById(R.id.ivHeartFavoriteItem)
private val videoTitle: AppCompatTextView = view.findViewById(R.id.tvTitleFavoriteItem)
override fun bindView(item: FavoriteItem, payloads: List<Any>) {
thumbnail.load(item.favoriteVideo.thumbnail)
videoTitle.text = item.favoriteVideo.title
// Favorite Icon
if (item.favoriteVideo.isChecked) favoriteIcon.setImageDrawable(
ContextCompat.getDrawable(
view.context,
R.drawable.ic_favorite_filled_border
)
) else favoriteIcon.setImageDrawable(
ContextCompat.getDrawable(
view.context,
R.drawable.ic_favorite_border
)
)
}
override fun unbindView(item: FavoriteItem) {
thumbnail.setImageDrawable(null)
videoTitle.text = null
}
}
}

View File

@ -0,0 +1,46 @@
package aculix.channelify.app.fastadapteritems
import aculix.channelify.app.R
import aculix.channelify.app.model.PlaylistItemInfo
import aculix.channelify.app.utils.DateTimeUtils
import android.view.View
import androidx.appcompat.widget.AppCompatImageView
import androidx.appcompat.widget.AppCompatTextView
import coil.api.load
import com.mikepenz.fastadapter.FastAdapter
import com.mikepenz.fastadapter.items.AbstractItem
import timber.log.Timber
class HomeItem(val playlistItem: PlaylistItemInfo.Item?) : AbstractItem<HomeItem.HomeViewHolder>() {
override val layoutRes: Int
get() = R.layout.item_home
override val type: Int
get() = R.id.fastadapter_home_item_id
override fun getViewHolder(v: View): HomeViewHolder {
return HomeViewHolder(v)
}
class HomeViewHolder(private var view: View) : FastAdapter.ViewHolder<HomeItem>(view) {
private val thumbnail: AppCompatImageView = view.findViewById(R.id.ivThumbnailHomeItem)
private val videoTitle: AppCompatTextView = view.findViewById(R.id.tvTitleHomeItem)
private val videoPublishedAt: AppCompatTextView = view.findViewById(R.id.tvTimePublishedHomeItem)
override fun bindView(item: HomeItem, payloads: List<Any>) {
item.playlistItem?.snippet?.let {
thumbnail.load(it.thumbnails.standard?.url ?: it.thumbnails.high.url)
videoTitle.text = it.title
}
videoPublishedAt.text = DateTimeUtils.getTimeAgo(item.playlistItem?.contentDetails?.videoPublishedAt!!)
}
override fun unbindView(item: HomeItem) {
thumbnail.setImageDrawable(null)
videoTitle.text = null
}
}
}

View File

@ -0,0 +1,47 @@
package aculix.channelify.app.fastadapteritems
import aculix.channelify.app.R
import aculix.channelify.app.model.Playlist
import android.view.View
import androidx.appcompat.widget.AppCompatImageView
import androidx.appcompat.widget.AppCompatTextView
import coil.api.load
import com.mikepenz.fastadapter.FastAdapter
import com.mikepenz.fastadapter.items.AbstractItem
class PlaylistItem(val playlistItem: Playlist.Item?) :
AbstractItem<PlaylistItem.PlaylistViewHolder>() {
override val layoutRes: Int
get() = R.layout.item_playlist
override val type: Int
get() = R.id.fastadapter_playlist_item_id
override fun getViewHolder(v: View): PlaylistViewHolder {
return PlaylistViewHolder(v)
}
class PlaylistViewHolder(private var view: View) : FastAdapter.ViewHolder<PlaylistItem>(view) {
private val thumbnail: AppCompatImageView = view.findViewById(R.id.ivThumbnailPlaylistItem)
private val playlistName: AppCompatTextView = view.findViewById(R.id.tvNamePlaylistItem)
private val videoCount: AppCompatTextView = view.findViewById(R.id.tvVideoCountPlaylistItem)
override fun bindView(item: PlaylistItem, payloads: List<Any>) {
thumbnail.load(
item.playlistItem?.snippet?.thumbnails?.standard?.url
?: item.playlistItem?.snippet?.thumbnails?.high?.url
)
playlistName.text = item.playlistItem?.snippet?.title
videoCount.text = view.context.resources.getQuantityString(R.plurals.text_playlist_video_count, item.playlistItem?.contentDetails?.itemCount!!, item.playlistItem.contentDetails.itemCount)
}
override fun unbindView(item: PlaylistItem) {
thumbnail.setImageDrawable(null)
playlistName.text = null
videoCount.text = null
}
}
}

View File

@ -0,0 +1,52 @@
package aculix.channelify.app.fastadapteritems
import aculix.channelify.app.R
import aculix.channelify.app.model.PlaylistItemInfo
import aculix.channelify.app.utils.DateTimeUtils
import android.view.View
import androidx.appcompat.widget.AppCompatImageView
import androidx.appcompat.widget.AppCompatTextView
import coil.api.load
import com.mikepenz.fastadapter.FastAdapter
import com.mikepenz.fastadapter.items.AbstractItem
class PlaylistVideoItem(val playlistItem: PlaylistItemInfo.Item?) :
AbstractItem<PlaylistVideoItem.PlaylistVideoViewHolder>() {
override val layoutRes: Int
get() = R.layout.item_playlist_video
override val type: Int
get() = R.id.fastadapter_playlist_video_item_id
override fun getViewHolder(v: View): PlaylistVideoViewHolder {
return PlaylistVideoViewHolder(v)
}
class PlaylistVideoViewHolder(private var view: View) :
FastAdapter.ViewHolder<PlaylistVideoItem>(view) {
private val thumbnail: AppCompatImageView =
view.findViewById(R.id.ivThumbnailPlaylistVideoItem)
private val videoTitle: AppCompatTextView = view.findViewById(R.id.tvTitlePlaylistVideoItem)
private val videoPublishedAt: AppCompatTextView =
view.findViewById(R.id.tvTimePublishedPlaylistVideoItem)
override fun bindView(item: PlaylistVideoItem, payloads: List<Any>) {
item.playlistItem?.snippet?.let {
thumbnail.load(it.thumbnails.standard?.url ?: it.thumbnails.high.url)
videoTitle.text = it.title
}
videoPublishedAt.text =
DateTimeUtils.getTimeAgo(item.playlistItem?.contentDetails?.videoPublishedAt!!)
}
override fun unbindView(item: PlaylistVideoItem) {
thumbnail.setImageDrawable(null)
videoTitle.text = null
videoPublishedAt.text = null
}
}
}

View File

@ -0,0 +1,31 @@
package aculix.channelify.app.fastadapteritems
import aculix.channelify.app.R
import android.view.View
import com.mikepenz.fastadapter.FastAdapter
import com.mikepenz.fastadapter.items.AbstractItem
class ProgressIndicatorItem : AbstractItem<ProgressIndicatorItem.ProgressIndicatorViewHolder>() {
override val type: Int
get() = R.id.progress_indicator_item_id
override val layoutRes: Int
get() = R.layout.item_progress_indicator
override fun getViewHolder(v: View): ProgressIndicatorViewHolder {
return ProgressIndicatorViewHolder(v)
}
class ProgressIndicatorViewHolder(view: View) : FastAdapter.ViewHolder<ProgressIndicatorItem>(view) {
override fun bindView(item: ProgressIndicatorItem, payloads: List<Any>) {
// No data needs to be set as only ProgressBar is shown
}
override fun unbindView(item: ProgressIndicatorItem) {
// No data set and hence no unbinding needed
}
}
}

View File

@ -0,0 +1,48 @@
package aculix.channelify.app.fastadapteritems
import aculix.channelify.app.R
import aculix.channelify.app.model.SearchedVideo
import aculix.channelify.app.utils.DateTimeUtils
import android.view.View
import androidx.appcompat.widget.AppCompatImageView
import androidx.appcompat.widget.AppCompatTextView
import coil.api.load
import com.mikepenz.fastadapter.FastAdapter
import com.mikepenz.fastadapter.items.AbstractItem
class SearchItem(val searchedVideo: SearchedVideo.Item?) :
AbstractItem<SearchItem.SearchViewHolder>() {
override val layoutRes: Int
get() = R.layout.item_search
override val type: Int
get() = R.id.fastadapter_search_item_id
override fun getViewHolder(v: View): SearchViewHolder {
return SearchViewHolder(v)
}
class SearchViewHolder(private var view: View) : FastAdapter.ViewHolder<SearchItem>(view) {
private val thumbnail: AppCompatImageView = view.findViewById(R.id.ivThumbnailSearchItem)
private val videoTitle: AppCompatTextView = view.findViewById(R.id.tvTitleSearchItem)
private val videoPublishedAt: AppCompatTextView =
view.findViewById(R.id.tvTimePublishedSearchItem)
override fun bindView(item: SearchItem, payloads: List<Any>) {
item.searchedVideo?.snippet?.let {
thumbnail.load(it.thumbnails.standard?.url ?: it.thumbnails.high.url)
videoTitle.text = it.title
videoPublishedAt.text = DateTimeUtils.getTimeAgo(it.publishedAt)
}
}
override fun unbindView(item: SearchItem) {
thumbnail.setImageDrawable(null)
videoTitle.text = null
videoPublishedAt.text = null
}
}
}

View File

@ -0,0 +1,224 @@
package aculix.channelify.app.fragment
import android.os.Bundle
import androidx.fragment.app.Fragment
import android.view.View
import aculix.channelify.app.R
import aculix.channelify.app.model.ChannelInfo
import aculix.channelify.app.sharedpref.AppPref
import aculix.channelify.app.utils.DateTimeUtils
import aculix.channelify.app.viewmodel.AboutViewModel
import aculix.core.extensions.*
import aculix.core.helper.ResultWrapper
import android.content.ActivityNotFoundException
import android.content.Intent
import android.content.res.Configuration
import android.net.Uri
import androidx.appcompat.app.AppCompatDelegate
import androidx.constraintlayout.widget.ConstraintSet
import androidx.core.content.ContextCompat
import androidx.lifecycle.Observer
import androidx.navigation.fragment.findNavController
import coil.api.load
import com.afollestad.materialdialogs.MaterialDialog
import com.afollestad.materialdialogs.list.listItemsSingleChoice
import kotlinx.android.synthetic.main.fragment_about.*
import kotlinx.android.synthetic.main.widget_toolbar.view.*
import org.koin.androidx.viewmodel.ext.android.viewModel
/**
* A simple [Fragment] subclass.
*/
class AboutFragment : Fragment(R.layout.fragment_about) {
private val viewModel by viewModel<AboutViewModel>() // Lazy inject ViewModel
private lateinit var channelInfoItem: ChannelInfo.Item
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupToolbar()
setupObservables()
fetchChannelInfo()
btnRetryAbout.setOnClickListener {
fetchChannelInfo()
}
btnSubscribeAbout.setOnClickListener {
startYouTubeIntent()
}
}
private fun setupToolbar() {
ablAbout.toolbarMain.apply {
inflateMenu(R.menu.toolbar_menu_about)
// Change theme menu item icon based on current theme
val themeDrawable = if (AppPref.isLightThemeEnabled) ContextCompat.getDrawable(
context!!,
R.drawable.ic_theme_light
) else ContextCompat.getDrawable(context!!, R.drawable.ic_theme_dark)
menu.findItem(R.id.miThemeAbout).icon = themeDrawable
// Store configuration
menu.findItem(R.id.miStoreAbout).isVisible = resources.getBoolean(R.bool.enable_store)
// MenuItem onclick
setOnMenuItemClickListener { item ->
when (item.itemId) {
R.id.miStoreAbout -> {
context.openUrl(getString(R.string.store_url), R.color.defaultBgColor)
}
R.id.miThemeAbout -> {
showThemeChooserDialog()
}
R.id.miAppInfoAbout -> {
findNavController().navigate(R.id.action_aboutFragment_to_appInfoFragment)
}
}
false
}
}
}
/**
* Fetches the info of channel
*/
private fun fetchChannelInfo() {
if (isInternetAvailable(context!!)) {
viewModel.getChannelInfo()
} else {
showChannelInfoErrorState()
}
}
private fun setupObservables() {
viewModel.channelInfoLiveData.observe(viewLifecycleOwner, Observer {
when (it) {
is ResultWrapper.Loading -> {
pbAbout.makeVisible()
}
is ResultWrapper.Error -> {
pbAbout.makeGone()
showChannelInfoErrorState()
}
is ResultWrapper.Success<*> -> {
pbAbout.makeGone()
hideChannelInfoErrorState()
channelInfoItem = (it.data as ChannelInfo).items[0]
setChannelInfo()
}
}
})
}
private fun setChannelInfo() {
// Banner
if (channelInfoItem.brandingSettings != null) {
ivBannerAbout.load(
channelInfoItem.brandingSettings?.image?.bannerMobileHdImageUrl
?: channelInfoItem.brandingSettings?.image?.bannerMobileMediumHdImageUrl
)
} else {
ivBannerAbout.makeGone()
val constraintSet = ConstraintSet().apply {
clone(clAbout)
connect(R.id.cvAbout, ConstraintSet.TOP, R.id.ablAbout, ConstraintSet.BOTTOM)
}
constraintSet.applyTo(clAbout)
}
// Profile Image
ivProfileAbout.load(
channelInfoItem.snippet.thumbnails.high?.url
?: channelInfoItem.snippet.thumbnails.medium.url
)
tvNameAbout.text = channelInfoItem.snippet.title
tvJoinDateAbout.text = getString(
R.string.text_channel_join_date,
DateTimeUtils.getPublishedDate(channelInfoItem.snippet.publishedAt)
)
tvSubscribersValueAbout.text =
channelInfoItem.statistics.subscriberCount.toLong().getFormattedNumberInString()
tvVideosValueAbout.text =
channelInfoItem.statistics.videoCount.toLong().getFormattedNumberInString()
tvViewsValueAbout.text =
channelInfoItem.statistics.viewCount.toLong().getFormattedNumberInString()
tvDescAbout.text = channelInfoItem.snippet.description
}
private fun showChannelInfoErrorState() {
groupResultAbout.makeGone()
groupErrorAbout.makeVisible()
}
private fun hideChannelInfoErrorState() {
groupErrorAbout.makeGone()
groupResultAbout.makeVisible()
}
/**
* Opens the YouTube app's Channel screen if YouTube app is installed otherwise opens the URL
* in Chrome Custom Tab.
*/
private fun startYouTubeIntent() {
try {
val youtubeIntent = Intent(
Intent.ACTION_VIEW,
Uri.parse(
getString(
R.string.text_youtube_channel_intent_url,
getString(R.string.channel_id)
)
)
)
startActivity(youtubeIntent)
} catch (exception: ActivityNotFoundException) {
context?.openUrl(
getString(
R.string.text_youtube_channel_intent_url,
getString(R.string.channel_id)
),
R.color.defaultBgColor
)
}
}
private fun showThemeChooserDialog() {
val themeList = listOf(
getString(R.string.dialog_theme_text_light),
getString(R.string.dialog_theme_text_dark)
)
val currentThemeIndex = if (AppPref.isLightThemeEnabled) 0 else 1
MaterialDialog(context!!).show {
title(R.string.dialog_theme_title)
listItemsSingleChoice(
items = themeList,
initialSelection = currentThemeIndex
) { dialog, index, text ->
when (text) {
getString(R.string.dialog_theme_text_light) -> {
setTheme(AppCompatDelegate.MODE_NIGHT_NO)
}
getString(R.string.dialog_theme_text_dark) -> {
setTheme(AppCompatDelegate.MODE_NIGHT_YES)
}
}
}
}
}
private fun setTheme(themeMode: Int) {
AppCompatDelegate.setDefaultNightMode(themeMode)
AppPref.isLightThemeEnabled = themeMode == AppCompatDelegate.MODE_NIGHT_NO
}
}

View File

@ -0,0 +1,118 @@
package aculix.channelify.app.fragment
import aculix.channelify.app.BuildConfig
import aculix.channelify.app.R
import aculix.core.extensions.openAppInGooglePlay
import aculix.core.extensions.openUrl
import aculix.core.extensions.startEmailIntent
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.View
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import androidx.navigation.ui.AppBarConfiguration
import androidx.navigation.ui.setupWithNavController
import coil.api.load
import kotlinx.android.synthetic.main.fragment_app_info.*
class AppInfoFragment : Fragment(R.layout.fragment_app_info) {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupToolbar()
ivLogoAppInfo.load(R.drawable.logo_splash)
onWebsiteClick()
onGooglePlayClick()
onInstagramClick()
onEmailClick()
onNavigationViewMenuItemClick()
}
private fun setupToolbar() {
val navController = findNavController()
val appBarConfiguration = AppBarConfiguration(navController.graph)
toolbarAppInfo.setupWithNavController(navController, appBarConfiguration)
}
private fun onWebsiteClick() {
ivWebsiteAppInfo.setOnClickListener {
context?.openUrl(getString(R.string.text_website_url), R.color.defaultBgColor)
}
}
private fun onGooglePlayClick() {
ivGooglePlayAppInfo.setOnClickListener {
try {
// Try to open in the Google Play app
startActivity(
Intent(
Intent.ACTION_VIEW,
Uri.parse("market://search?q=pub:${getString(R.string.text_google_play_developer_name)}")
)
)
} catch (exception: Throwable) {
// Google Play app is not installed. Open URL in the browser.
context?.openUrl(
"https://play.google.com/store/apps/dev?id=${getString(R.string.text_google_play_developer_id)}",
R.color.defaultBgColor
)
}
}
}
private fun onInstagramClick() {
ivInstagramAppInfo.setOnClickListener {
try {
// Try to open in the Instagram app
startActivity(
Intent(
Intent.ACTION_VIEW,
Uri.parse("https://instagram.com/_u/${getString(R.string.text_instagram_user_name)}")
)
)
} catch (exception: android.content.ActivityNotFoundException) {
// Instagram app is not installed. Open URL in the browser.
context?.openUrl(
"https://instagram.com/${getString(R.string.text_instagram_user_name)}",
R.color.defaultBgColor
)
}
}
}
private fun onEmailClick() {
ivEmailAppInfo.setOnClickListener {
context?.startEmailIntent(getString(R.string.text_contact_email), null)
}
}
private fun onNavigationViewMenuItemClick() {
nvAppInfo.setNavigationItemSelectedListener {
when (it.itemId) {
R.id.miRateAppInfo -> {
context?.openAppInGooglePlay(BuildConfig.APPLICATION_ID)
}
R.id.miFeedbackAppInfo -> {
context?.startEmailIntent(
getString(R.string.text_contact_email),
getString(R.string.text_app_feedback_email_subject)
)
}
R.id.miTosAppInfo -> {
context?.openUrl(getString(R.string.text_tos_url), R.color.defaultBgColor)
}
R.id.miPrivacyPolicyAppInfo -> {
context?.openUrl(
getString(R.string.text_privacy_policy_url),
R.color.defaultBgColor
)
}
}
true
}
}
}

View File

@ -0,0 +1,206 @@
package aculix.channelify.app.fragment
import aculix.channelify.app.Channelify
import aculix.channelify.app.R
import aculix.channelify.app.fastadapteritems.CommentReplyItem
import aculix.channelify.app.fastadapteritems.ProgressIndicatorItem
import aculix.channelify.app.model.CommentReply
import aculix.channelify.app.paging.Status
import aculix.channelify.app.utils.DividerItemDecorator
import aculix.channelify.app.utils.getAdaptiveBannerAdSize
import aculix.channelify.app.viewmodel.CommentRepliesViewModel
import aculix.core.extensions.makeGone
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.paging.PagedList
import androidx.recyclerview.widget.AsyncDifferConfig
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.gms.ads.AdRequest
import com.google.android.gms.ads.AdView
import com.google.android.material.bottomnavigation.BottomNavigationView
import com.google.android.material.snackbar.Snackbar
import com.mikepenz.fastadapter.FastAdapter
import com.mikepenz.fastadapter.GenericFastAdapter
import com.mikepenz.fastadapter.adapters.GenericItemAdapter
import com.mikepenz.fastadapter.adapters.ItemAdapter
import com.mikepenz.fastadapter.paged.PagedModelAdapter
import kotlinx.android.synthetic.main.fragment_comment_replies.*
import kotlinx.android.synthetic.main.fragment_comments.*
import org.koin.androidx.viewmodel.ext.android.viewModel
class CommentRepliesFragment : Fragment() {
private val viewModel by viewModel<CommentRepliesViewModel>() // Lazy inject ViewModel
private val args by navArgs<CommentRepliesFragmentArgs>()
private lateinit var commentId: String
private lateinit var commentRepliesAdapter: GenericFastAdapter
private lateinit var commentRepliesPagedModelAdapter: PagedModelAdapter<CommentReply.Item, CommentReplyItem>
private var retrySnackbar: Snackbar? = null
private lateinit var footerAdapter: GenericItemAdapter
private var isFirstPageLoading = true
private lateinit var adView: AdView
private var initialLayoutComplete = false
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
// Inflate the layout for this fragment
return inflater.inflate(R.layout.fragment_comment_replies, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
commentId = args.commentId
setupRecyclerView(savedInstanceState)
fetchCommentReplies()
setupObservables()
ivCloseCommentReplies.setOnClickListener { onCloseClick() }
if (Channelify.isAdEnabled) setupAd() else adViewContainerCommentReplies.makeGone()
}
override fun onSaveInstanceState(_outState: Bundle) {
var outState = _outState
outState = commentRepliesAdapter.saveInstanceState(outState)
super.onSaveInstanceState(outState)
}
override fun onPause() {
retrySnackbar?.dismiss() // Dismiss the retrySnackbar if already present
if (Channelify.isAdEnabled) adView.pause()
super.onPause()
}
override fun onResume() {
if (Channelify.isAdEnabled) adView.resume()
super.onResume()
}
override fun onDestroy() {
if (Channelify.isAdEnabled) adView.destroy()
super.onDestroy()
}
private fun setupRecyclerView(savedInstanceState: Bundle?) {
val asyncDifferConfig = AsyncDifferConfig.Builder<CommentReply.Item>(object :
DiffUtil.ItemCallback<CommentReply.Item>() {
override fun areItemsTheSame(
oldItem: CommentReply.Item,
newItem: CommentReply.Item
): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(
oldItem: CommentReply.Item,
newItem: CommentReply.Item
): Boolean {
return oldItem == newItem
}
}).build()
commentRepliesPagedModelAdapter =
PagedModelAdapter<CommentReply.Item, CommentReplyItem>(asyncDifferConfig) {
CommentReplyItem(it)
}
footerAdapter = ItemAdapter.items()
commentRepliesAdapter =
FastAdapter.with(listOf(commentRepliesPagedModelAdapter, footerAdapter))
commentRepliesAdapter.registerTypeInstance(CommentReplyItem(null))
commentRepliesAdapter.withSavedInstanceState(savedInstanceState)
rvCommentReplies.layoutManager = LinearLayoutManager(context)
rvCommentReplies.addItemDecoration(
DividerItemDecorator(ContextCompat.getDrawable(requireContext(), R.drawable.view_divider_item_decorator)!!)
)
rvCommentReplies.adapter = commentRepliesAdapter
}
private fun setupObservables() {
// Observe network live data
viewModel.networkStateLiveData?.observe(viewLifecycleOwner, Observer { networkState ->
when (networkState?.status) {
Status.FAILED -> {
footerAdapter.clear()
createRetrySnackbar()
retrySnackbar?.show()
}
Status.SUCCESS -> {
footerAdapter.clear()
}
Status.LOADING -> {
if (!isFirstPageLoading) {
showRecyclerViewProgressIndicator()
} else {
isFirstPageLoading = false
}
}
}
})
// Observe latest video live data
viewModel.commentRepliesLiveData?.observe(
viewLifecycleOwner,
Observer<PagedList<CommentReply.Item>> { commentRepliesList ->
commentRepliesPagedModelAdapter.submitList(commentRepliesList)
})
}
private fun showRecyclerViewProgressIndicator() {
footerAdapter.clear()
val progressIndicatorItem = ProgressIndicatorItem()
footerAdapter.add(progressIndicatorItem)
}
private fun fetchCommentReplies() {
viewModel.getCommentReplies(commentId)
}
private fun createRetrySnackbar() {
retrySnackbar =
Snackbar.make(
clCommentReplies,
R.string.error_fetch_comment_replies,
Snackbar.LENGTH_INDEFINITE
)
.setAction(R.string.btn_retry) {
viewModel.refreshFailedRequest()
}
}
private fun onCloseClick() {
findNavController().popBackStack()
}
private fun setupAd() {
adView = AdView(context)
adViewContainerCommentReplies.addView(adView)
adViewContainerCommentReplies.viewTreeObserver.addOnGlobalLayoutListener {
if (!initialLayoutComplete) {
initialLayoutComplete = true
adView.adUnitId = getString(R.string.comment_replies_banner_ad_id)
adView.adSize = activity?.getAdaptiveBannerAdSize(adViewContainerCommentReplies)
adView.loadAd(AdRequest.Builder().build())
}
}
}
}

View File

@ -0,0 +1,272 @@
package aculix.channelify.app.fragment
import aculix.channelify.app.Channelify
import aculix.channelify.app.R
import aculix.channelify.app.fastadapteritems.CommentItem
import aculix.channelify.app.fastadapteritems.ProgressIndicatorItem
import aculix.channelify.app.model.Comment
import aculix.channelify.app.paging.Status
import aculix.channelify.app.utils.DividerItemDecorator
import aculix.channelify.app.utils.getAdaptiveBannerAdSize
import aculix.channelify.app.viewmodel.CommentsViewModel
import aculix.core.extensions.makeGone
import aculix.core.extensions.makeVisible
import aculix.core.extensions.toast
import android.os.Bundle
import android.view.*
import androidx.appcompat.widget.PopupMenu
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.paging.PagedList
import androidx.recyclerview.widget.*
import com.google.android.gms.ads.AdRequest
import com.google.android.gms.ads.AdSize
import com.google.android.gms.ads.AdView
import com.google.android.material.bottomnavigation.BottomNavigationView
import com.google.android.material.snackbar.Snackbar
import com.mikepenz.fastadapter.FastAdapter
import com.mikepenz.fastadapter.GenericFastAdapter
import com.mikepenz.fastadapter.adapters.GenericItemAdapter
import com.mikepenz.fastadapter.adapters.ItemAdapter
import com.mikepenz.fastadapter.listeners.ClickEventHook
import com.mikepenz.fastadapter.paged.PagedModelAdapter
import kotlinx.android.synthetic.main.fragment_comments.*
import kotlinx.android.synthetic.main.item_comment.view.*
import org.koin.androidx.viewmodel.ext.android.viewModel
import timber.log.Timber
class CommentsFragment : Fragment() {
private val viewModel by viewModel<CommentsViewModel>() // Lazy inject ViewModel
private val args by navArgs<CommentsFragmentArgs>()
private lateinit var videoId: String
private lateinit var commentsAdapter: GenericFastAdapter
private lateinit var commentsPagedModelAdapter: PagedModelAdapter<Comment.Item, CommentItem>
private lateinit var footerAdapter: GenericItemAdapter
private var isFirstPageLoading = true
private var retrySnackbar: Snackbar? = null
private val SORT_BY_RELEVANCE = "relevance"
private val SORT_BY_TIME = "time"
private lateinit var adView: AdView
private var initialLayoutComplete = false
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_comments, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
videoId = args.videoId
setupRecyclerView(savedInstanceState)
fetchComments(SORT_BY_RELEVANCE)
setupObservables()
ivSortComments.setOnClickListener { onSortClick(it) }
ivCloseComments.setOnClickListener { onCloseClick() }
if (Channelify.isAdEnabled) setupAd() else adViewContainerComments.makeGone()
}
override fun onSaveInstanceState(_outState: Bundle) {
var outState = _outState
outState = commentsAdapter.saveInstanceState(outState)
super.onSaveInstanceState(outState)
}
override fun onPause() {
retrySnackbar?.dismiss() // Dismiss the retrySnackbar if already present
if (Channelify.isAdEnabled) adView.pause()
super.onPause()
}
override fun onResume() {
if (Channelify.isAdEnabled) adView.resume()
super.onResume()
}
override fun onDestroy() {
if (Channelify.isAdEnabled) adView.destroy()
super.onDestroy()
}
private fun setupRecyclerView(savedInstanceState: Bundle?) {
val asyncDifferConfig = AsyncDifferConfig.Builder<Comment.Item>(object :
DiffUtil.ItemCallback<Comment.Item>() {
override fun areItemsTheSame(
oldItem: Comment.Item,
newItem: Comment.Item
): Boolean {
return oldItem.snippet.topLevelComment.id == newItem.snippet.topLevelComment.id
}
override fun areContentsTheSame(
oldItem: Comment.Item,
newItem: Comment.Item
): Boolean {
return oldItem == newItem
}
}).build()
commentsPagedModelAdapter =
PagedModelAdapter<Comment.Item, CommentItem>(asyncDifferConfig) {
CommentItem(it)
}
footerAdapter = ItemAdapter.items()
commentsAdapter = FastAdapter.with(listOf(commentsPagedModelAdapter, footerAdapter))
commentsAdapter.registerTypeInstance(CommentItem(null))
commentsAdapter.withSavedInstanceState(savedInstanceState)
onViewRepliesClick()
rvComments.layoutManager = LinearLayoutManager(context)
rvComments.addItemDecoration(DividerItemDecorator(ContextCompat.getDrawable(requireContext(), R.drawable.view_divider_item_decorator)!!))
rvComments.adapter = commentsAdapter
}
private fun setupObservables() {
// Observe Empty State LiveData
viewModel.emptyStateLiveData.observe(viewLifecycleOwner, Observer { isResultEmpty ->
if (isResultEmpty) {
showEmptyState()
} else {
hideEmptyState()
}
})
// Observe network live data
viewModel.networkStateLiveData?.observe(viewLifecycleOwner, Observer { networkState ->
when (networkState?.status) {
Status.FAILED -> {
footerAdapter.clear()
createRetrySnackbar()
retrySnackbar?.show()
}
Status.SUCCESS -> {
footerAdapter.clear()
}
Status.LOADING -> {
if (!isFirstPageLoading) {
showRecyclerViewProgressIndicator()
} else {
isFirstPageLoading = false
}
}
}
})
// Observe latest video live data
viewModel.commentsLiveData?.observe(
viewLifecycleOwner,
Observer<PagedList<Comment.Item>> { commentsList ->
commentsPagedModelAdapter.submitList(commentsList)
})
}
private fun showRecyclerViewProgressIndicator() {
footerAdapter.clear()
val progressIndicatorItem = ProgressIndicatorItem()
footerAdapter.add(progressIndicatorItem)
}
private fun showEmptyState() {
groupEmptyComments.makeVisible()
}
private fun hideEmptyState() {
groupEmptyComments.makeGone()
}
private fun fetchComments(sortOrder: String) {
viewModel.getVideoComments(videoId, sortOrder)
}
private fun createRetrySnackbar() {
retrySnackbar =
Snackbar.make(clComments, R.string.error_fetch_comments, Snackbar.LENGTH_INDEFINITE)
.setAction(R.string.btn_retry) {
viewModel.refreshFailedRequest()
}
}
/**
* Called when the Sort icon is clicked
*/
private fun onSortClick(view: View) {
val sortMenu = PopupMenu(requireContext(), view)
sortMenu.menuInflater.inflate(R.menu.comments_sort_menu_video_details, sortMenu.menu)
sortMenu.show()
sortMenu.setOnMenuItemClickListener { item: MenuItem? ->
when (item?.itemId) {
R.id.miTopComments -> {
viewModel.sortComments(SORT_BY_RELEVANCE)
}
R.id.miNewestFirstComments -> {
viewModel.sortComments(SORT_BY_TIME)
Timber.e("Newest Comments")
}
}
false
}
}
private fun onCloseClick() {
findNavController().popBackStack()
}
/**
* Called when the View Replies button is clicked of a
* Comment Item
*/
private fun onViewRepliesClick() {
commentsAdapter.addEventHook(object : ClickEventHook<CommentItem>() {
override fun onBind(viewHolder: RecyclerView.ViewHolder): View? {
return if (viewHolder is CommentItem.CommentViewHolder) {
viewHolder.itemView.btnViewRepliesCommentItem
} else {
null
}
}
override fun onClick(
v: View,
position: Int,
fastAdapter: FastAdapter<CommentItem>,
item: CommentItem
) {
val action =
CommentsFragmentDirections.actionCommentsFragmentToCommentRepliesFragment(item.comment?.snippet?.topLevelComment?.id!!)
findNavController().navigate(action)
}
})
}
private fun setupAd() {
adView = AdView(context)
adViewContainerComments.addView(adView)
adViewContainerComments.viewTreeObserver.addOnGlobalLayoutListener {
if (!initialLayoutComplete) {
initialLayoutComplete = true
adView.adUnitId = getString(R.string.comments_banner_ad_id)
adView.adSize = activity?.getAdaptiveBannerAdSize(adViewContainerComments)
adView.loadAd(AdRequest.Builder().build())
}
}
}
}

View File

@ -0,0 +1,159 @@
package aculix.channelify.app.fragment
import android.os.Bundle
import androidx.fragment.app.Fragment
import android.view.View
import aculix.channelify.app.R
import aculix.channelify.app.activity.VideoPlayerActivity
import aculix.channelify.app.fastadapteritems.FavoriteItem
import aculix.channelify.app.viewmodel.FavoritesViewModel
import aculix.core.extensions.openUrl
import androidx.appcompat.widget.AppCompatImageView
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.lifecycle.Observer
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.mikepenz.fastadapter.FastAdapter
import com.mikepenz.fastadapter.adapters.FastItemAdapter
import com.mikepenz.fastadapter.listeners.ClickEventHook
import com.mikepenz.itemanimators.AlphaInAnimator
import kotlinx.android.synthetic.main.fragment_favorites.*
import kotlinx.android.synthetic.main.item_favorite.view.*
import kotlinx.android.synthetic.main.widget_toolbar.view.*
import org.koin.androidx.viewmodel.ext.android.viewModel
/**
* A simple [Fragment] subclass.
*/
class FavoritesFragment : Fragment(R.layout.fragment_favorites) {
private val viewModel by viewModel<FavoritesViewModel>() // Lazy inject ViewModel
private lateinit var favoritesAdapter: FastItemAdapter<FavoriteItem>
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupToolbar()
setupRecyclerView(savedInstanceState)
setupObservables()
}
override fun onSaveInstanceState(_outState: Bundle) {
var outState = _outState
outState = favoritesAdapter.saveInstanceState(outState)
super.onSaveInstanceState(outState)
}
private fun setupToolbar() {
ablFavorites.toolbarMain.apply {
inflateMenu(R.menu.main_menu)
// Store and Search configuration
menu.findItem(R.id.miStoreMainMenu).isVisible = resources.getBoolean(R.bool.enable_store)
menu.findItem(R.id.miSearchMainMenu).isVisible = resources.getBoolean(R.bool.enable_search)
setOnMenuItemClickListener { item ->
when (item.itemId) {
R.id.miStoreMainMenu -> {
context.openUrl(getString(R.string.store_url), R.color.defaultBgColor)
}
R.id.miSearchMainMenu -> {
findNavController().navigate(R.id.action_favoritesFragment_to_searchFragment)
}
}
false
}
}
}
private fun setupRecyclerView(savedInstanceState: Bundle?) {
favoritesAdapter = FastItemAdapter()
favoritesAdapter.setHasStableIds(true)
favoritesAdapter.withSavedInstanceState(savedInstanceState)
rvFavorites.layoutManager = LinearLayoutManager(context)
rvFavorites.itemAnimator = AlphaInAnimator()
rvFavorites.adapter = favoritesAdapter
rvFavorites.itemAnimator = AlphaInAnimator()
onFavoriteClick()
onItemClick()
}
private fun setupObservables() {
viewModel.favoriteVideosLiveData.observe(viewLifecycleOwner, Observer { favoriteVideoList ->
val favoriteItemsList = ArrayList<FavoriteItem>()
for (favoriteVideo in favoriteVideoList) {
favoriteItemsList.add(FavoriteItem(favoriteVideo))
}
favoritesAdapter.add(favoriteItemsList)
showEmptyState(favoritesAdapter.itemCount)
})
}
private fun showEmptyState(itemCount: Int) {
groupEmptyFavorites.isVisible = itemCount < 1
}
/**
* Called when the Heart Icon is clicked of a RecyclerView Item
*/
private fun onFavoriteClick() {
favoritesAdapter.addEventHook(object : ClickEventHook<FavoriteItem>() {
override fun onBind(viewHolder: RecyclerView.ViewHolder): View? {
return if (viewHolder is FavoriteItem.FavoriteViewHolder) {
viewHolder.itemView.ivHeartFavoriteItem
} else {
null
}
}
override fun onClick(
v: View,
position: Int,
fastAdapter: FastAdapter<FavoriteItem>,
item: FavoriteItem
) {
val favoriteIcon = v as AppCompatImageView
if (item.favoriteVideo.isChecked) {
// Icon unchecked
item.favoriteVideo.isChecked = false
favoriteIcon.setImageDrawable(
ContextCompat.getDrawable(
context!!,
R.drawable.ic_favorite_border
)
)
viewModel.removeVideoFromFavorites(item.favoriteVideo)
} else {
// Icon checked
item.favoriteVideo.isChecked = true
favoriteIcon.setImageDrawable(
ContextCompat.getDrawable(
context!!,
R.drawable.ic_favorite_filled_border
)
)
viewModel.addVideoToFavorites(item.favoriteVideo)
}
}
})
}
/**
* Called when an item of the RecyclerView is clicked
*/
private fun onItemClick() {
favoritesAdapter.onClickListener = { view, adapter, item, position ->
VideoPlayerActivity.startActivity(context, item.favoriteVideo.id)
false
}
}
}

View File

@ -0,0 +1,247 @@
package aculix.channelify.app.fragment
import aculix.channelify.app.R
import aculix.channelify.app.activity.VideoPlayerActivity
import aculix.channelify.app.fastadapteritems.HomeItem
import aculix.channelify.app.fastadapteritems.ProgressIndicatorItem
import aculix.channelify.app.model.PlaylistItemInfo
import aculix.channelify.app.paging.Status
import aculix.channelify.app.utils.DividerItemDecorator
import aculix.channelify.app.viewmodel.HomeViewModel
import aculix.core.extensions.*
import aculix.core.helper.ResultWrapper
import android.os.Bundle
import android.view.View
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import androidx.navigation.fragment.findNavController
import androidx.paging.PagedList
import androidx.recyclerview.widget.AsyncDifferConfig
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.gms.ads.AdListener
import com.google.android.gms.ads.AdRequest
import com.google.android.gms.ads.InterstitialAd
import com.google.android.material.bottomnavigation.BottomNavigationView
import com.google.android.material.snackbar.Snackbar
import com.mikepenz.fastadapter.FastAdapter
import com.mikepenz.fastadapter.GenericFastAdapter
import com.mikepenz.fastadapter.adapters.GenericItemAdapter
import com.mikepenz.fastadapter.adapters.ItemAdapter
import com.mikepenz.fastadapter.paged.ExperimentalPagedSupport
import com.mikepenz.fastadapter.paged.PagedModelAdapter
import kotlinx.android.synthetic.main.fragment_home.*
import kotlinx.android.synthetic.main.widget_toolbar.view.*
import org.koin.androidx.viewmodel.ext.android.viewModel
@ExperimentalPagedSupport
class HomeFragment : Fragment(R.layout.fragment_home) {
private val viewModel by viewModel<HomeViewModel>() // Lazy inject ViewModel
private var homeAdapter: GenericFastAdapter? = null
private lateinit var homePagedModelAdapter: PagedModelAdapter<PlaylistItemInfo.Item, HomeItem>
private lateinit var footerAdapter: GenericItemAdapter
private var isFirstPageLoading = true
private var retrySnackbar: Snackbar? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupToolbar()
if (requireContext().isInternetAvailable()) {
viewModel.getLatestVideos()
} else {
showErrorState()
}
setupUploadsPlaylistIdObservables()
setupRecyclerView(savedInstanceState)
onRetryButtonClick()
}
override fun onSaveInstanceState(_outState: Bundle) {
homeAdapter?.let {
var outState = _outState
outState = it.saveInstanceState(outState)
super.onSaveInstanceState(outState)
}
}
override fun onPause() {
super.onPause()
retrySnackbar?.dismiss() // Dismiss the retrySnackbar if already present
}
private fun setupToolbar() {
ablHome.toolbarMain.apply {
inflateMenu(R.menu.main_menu)
// Store and Search configuration
menu.findItem(R.id.miStoreMainMenu).isVisible =
resources.getBoolean(R.bool.enable_store)
menu.findItem(R.id.miSearchMainMenu).isVisible =
resources.getBoolean(R.bool.enable_search)
setOnMenuItemClickListener { item ->
when (item.itemId) {
R.id.miStoreMainMenu -> {
context.openUrl(getString(R.string.store_url), R.color.defaultBgColor)
}
R.id.miSearchMainMenu -> {
findNavController().navigate(R.id.action_homeFragment_to_searchFragment)
}
}
false
}
}
}
private fun setupRecyclerView(savedInstanceState: Bundle?) {
val asyncDifferConfig = AsyncDifferConfig.Builder<PlaylistItemInfo.Item>(object :
DiffUtil.ItemCallback<PlaylistItemInfo.Item>() {
override fun areItemsTheSame(
oldItem: PlaylistItemInfo.Item,
newItem: PlaylistItemInfo.Item
): Boolean {
return oldItem.contentDetails.videoId == newItem.contentDetails.videoId
}
override fun areContentsTheSame(
oldItem: PlaylistItemInfo.Item,
newItem: PlaylistItemInfo.Item
): Boolean {
return oldItem == newItem
}
}).build()
homePagedModelAdapter =
PagedModelAdapter<PlaylistItemInfo.Item, HomeItem>(asyncDifferConfig) {
HomeItem(it)
}
footerAdapter = ItemAdapter.items()
homeAdapter = FastAdapter.with(listOf(homePagedModelAdapter, footerAdapter))
homeAdapter?.registerTypeInstance(HomeItem(null))
homeAdapter?.withSavedInstanceState(savedInstanceState)
rvHome.layoutManager = LinearLayoutManager(context)
rvHome.adapter = homeAdapter
rvHome.addItemDecoration(
DividerItemDecorator(
ContextCompat.getDrawable(
requireContext(),
R.drawable.view_divider_item_decorator
)!!
)
)
onItemClick()
}
private fun setupUploadsPlaylistIdObservables() {
viewModel.uploadsPlaylistIdLiveData.observe(viewLifecycleOwner, Observer {
when (it) {
is ResultWrapper.Loading -> {
// Data is always fetched from the next page and hence loadBefore is never needed
}
is ResultWrapper.Error -> {
// Error occurred while fetching the uploads playlist id
showErrorState(it.errorMessage)
}
is ResultWrapper.Success<*> -> {
// Success in fetching the uploads playlist id
hideErrorState()
setupLatestVideosObservables()
}
}
})
}
private fun setupLatestVideosObservables() {
// Observe network live data
viewModel.networkStateLiveData?.observe(viewLifecycleOwner, Observer { networkState ->
when (networkState?.status) {
Status.FAILED -> {
footerAdapter.clear()
pbHome.makeGone()
createRetrySnackbar()
retrySnackbar?.show()
}
Status.SUCCESS -> {
footerAdapter.clear()
pbHome.makeGone()
}
Status.LOADING -> {
if (!isFirstPageLoading) {
showRecyclerViewProgressIndicator()
} else {
isFirstPageLoading = false
}
}
}
})
// Observe latest video live data
viewModel.latestVideoLiveData?.observe(
viewLifecycleOwner,
Observer<PagedList<PlaylistItemInfo.Item>> { latestVideoList ->
homePagedModelAdapter.submitList(latestVideoList)
})
}
private fun showRecyclerViewProgressIndicator() {
footerAdapter.clear()
val progressIndicatorItem = ProgressIndicatorItem()
footerAdapter.add(progressIndicatorItem)
}
private fun showErrorState(errorMsg: String = getString(R.string.error_internet_connectivity)) {
rvHome.makeGone()
pbHome.makeGone()
groupErrorHome.makeVisible()
tvErrorHome.text = errorMsg
}
private fun hideErrorState() {
groupErrorHome.makeGone()
rvHome.makeVisible()
}
/**
* Called when the Retry button of the error state is clicked
*/
private fun onRetryButtonClick() {
btnRetryHome.setOnClickListener {
if (requireContext().isInternetAvailable()) viewModel.getLatestVideos()
}
}
/**
* Called when an item of the RecyclerView is clicked
*/
private fun onItemClick() {
homeAdapter?.onClickListener = { view, adapter, item, position ->
if (item is HomeItem) {
VideoPlayerActivity.startActivity(
context,
item.playlistItem?.contentDetails?.videoId!!
)
}
false
}
}
private fun createRetrySnackbar() {
retrySnackbar =
Snackbar.make(clHome, R.string.error_load_more_videos, Snackbar.LENGTH_INDEFINITE)
.setAnchorView(activity?.findViewById(R.id.bottomNavView) as BottomNavigationView)
.setAction(R.string.btn_retry) {
viewModel.refreshFailedRequest()
}
}
}

View File

@ -0,0 +1,249 @@
package aculix.channelify.app.fragment
import android.os.Bundle
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import aculix.channelify.app.R
import aculix.channelify.app.activity.VideoPlayerActivity
import aculix.channelify.app.fastadapteritems.HomeItem
import aculix.channelify.app.fastadapteritems.PlaylistVideoItem
import aculix.channelify.app.fastadapteritems.ProgressIndicatorItem
import aculix.channelify.app.model.PlaylistItemInfo
import aculix.channelify.app.paging.Status
import aculix.channelify.app.utils.DateTimeUtils
import aculix.channelify.app.utils.DividerItemDecorator
import aculix.channelify.app.viewmodel.HomeViewModel
import aculix.channelify.app.viewmodel.PlaylistVideosViewModel
import aculix.core.extensions.makeGone
import aculix.core.extensions.startShareTextIntent
import androidx.core.content.ContextCompat
import androidx.lifecycle.Observer
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.navigation.ui.AppBarConfiguration
import androidx.navigation.ui.setupWithNavController
import androidx.paging.PagedList
import androidx.recyclerview.widget.AsyncDifferConfig
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import coil.api.load
import com.afollestad.materialdialogs.LayoutMode
import com.afollestad.materialdialogs.MaterialDialog
import com.afollestad.materialdialogs.bottomsheets.BottomSheet
import com.afollestad.materialdialogs.customview.customView
import com.afollestad.materialdialogs.customview.getCustomView
import com.google.android.material.bottomnavigation.BottomNavigationView
import com.google.android.material.snackbar.Snackbar
import com.mikepenz.fastadapter.FastAdapter
import com.mikepenz.fastadapter.GenericFastAdapter
import com.mikepenz.fastadapter.adapters.GenericItemAdapter
import com.mikepenz.fastadapter.adapters.ItemAdapter
import com.mikepenz.fastadapter.paged.PagedModelAdapter
import kotlinx.android.synthetic.main.fragment_home.*
import kotlinx.android.synthetic.main.fragment_playlist_details.view.*
import kotlinx.android.synthetic.main.fragment_playlist_videos.*
import org.koin.androidx.viewmodel.ext.android.viewModel
/**
* A simple [Fragment] subclass to show the list of videos of a particular playlist.
*/
class PlaylistVideosFragment : Fragment() {
private val viewModel by viewModel<PlaylistVideosViewModel>() // Lazy inject ViewModel
private val args by navArgs<PlaylistVideosFragmentArgs>()
private lateinit var playlistVideosAdapter: GenericFastAdapter
private lateinit var playlistVideosPagedModelAdapter: PagedModelAdapter<PlaylistItemInfo.Item, PlaylistVideoItem>
private lateinit var footerAdapter: GenericItemAdapter
private var isFirstPageLoading = true
private var retrySnackbar: Snackbar? = null
private lateinit var playlistId: String
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
// Inflate the layout for this fragment
return inflater.inflate(R.layout.fragment_playlist_videos, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
playlistId = args.playlistId
setupToolbar()
setupRecyclerView(savedInstanceState)
fetchPlaylistVideos()
setupObservables()
}
override fun onSaveInstanceState(_outState: Bundle) {
var outState = _outState
outState = playlistVideosAdapter.saveInstanceState(outState)
super.onSaveInstanceState(outState)
}
override fun onPause() {
super.onPause()
retrySnackbar?.dismiss() // Dismiss the retrySnackbar if already present
}
private fun setupToolbar() {
val navController = findNavController()
val appBarConfiguration = AppBarConfiguration(navController.graph)
toolbarPlaylistVideos.setupWithNavController(navController, appBarConfiguration)
// Inflate Menu
toolbarPlaylistVideos.inflateMenu(R.menu.toolbar_menu_playlist_videos)
toolbarPlaylistVideos.setOnMenuItemClickListener { item ->
when (item.itemId) {
R.id.miDetailsPlaylistVideos -> {
showPlaylistDetails()
}
R.id.miSharePlaylistVideos -> {
sharePlaylistUrl()
}
}
true
}
}
private fun setupRecyclerView(savedInstanceState: Bundle?) {
val asyncDifferConfig = AsyncDifferConfig.Builder<PlaylistItemInfo.Item>(object :
DiffUtil.ItemCallback<PlaylistItemInfo.Item>() {
override fun areItemsTheSame(
oldItem: PlaylistItemInfo.Item,
newItem: PlaylistItemInfo.Item
): Boolean {
return oldItem.contentDetails.videoId == newItem.contentDetails.videoId
}
override fun areContentsTheSame(
oldItem: PlaylistItemInfo.Item,
newItem: PlaylistItemInfo.Item
): Boolean {
return oldItem == newItem
}
}).build()
playlistVideosPagedModelAdapter =
PagedModelAdapter<PlaylistItemInfo.Item, PlaylistVideoItem>(asyncDifferConfig) {
PlaylistVideoItem(it)
}
footerAdapter = ItemAdapter.items()
playlistVideosAdapter =
FastAdapter.with(listOf(playlistVideosPagedModelAdapter, footerAdapter))
playlistVideosAdapter.registerTypeInstance(PlaylistVideoItem(null))
playlistVideosAdapter.withSavedInstanceState(savedInstanceState)
rvPlaylistVideos.layoutManager = LinearLayoutManager(context)
rvPlaylistVideos.adapter = playlistVideosAdapter
rvPlaylistVideos.addItemDecoration(
DividerItemDecorator(ContextCompat.getDrawable(requireContext(), R.drawable.view_divider_item_decorator)!!)
)
onItemClick()
}
private fun fetchPlaylistVideos() {
viewModel.getPlaylistVideos(playlistId)
}
private fun setupObservables() {
// Observe network live data
viewModel.networkStateLiveData?.observe(viewLifecycleOwner, Observer { networkState ->
when (networkState?.status) {
Status.FAILED -> {
footerAdapter.clear()
createRetrySnackbar()
retrySnackbar?.show()
}
Status.SUCCESS -> {
footerAdapter.clear()
}
Status.LOADING -> {
if (!isFirstPageLoading) {
showRecyclerViewProgressIndicator()
} else {
isFirstPageLoading = false
}
}
}
})
// Observe latest video live data
viewModel.playlistVideosLiveData?.observe(
viewLifecycleOwner,
Observer<PagedList<PlaylistItemInfo.Item>> { playlistVideosList ->
playlistVideosPagedModelAdapter.submitList(playlistVideosList)
})
}
private fun showRecyclerViewProgressIndicator() {
footerAdapter.clear()
val progressIndicatorItem = ProgressIndicatorItem()
footerAdapter.add(progressIndicatorItem)
}
private fun createRetrySnackbar() {
retrySnackbar =
Snackbar.make(
clPlaylistVideos,
R.string.error_load_more_videos,
Snackbar.LENGTH_INDEFINITE
)
.setAnchorView(activity?.findViewById(R.id.bottomNavView) as BottomNavigationView)
.setAction(R.string.btn_retry) {
viewModel.refreshFailedRequest()
}
}
/**
* Called when an item of the RecyclerView is clicked
*/
private fun onItemClick() {
playlistVideosAdapter.onClickListener = { view, adapter, item, position ->
if (item is PlaylistVideoItem) {
VideoPlayerActivity.startActivity(context, item.playlistItem?.contentDetails?.videoId!!)
}
false
}
}
private fun showPlaylistDetails() {
val playlistDetailsDialog =
MaterialDialog(requireContext(), BottomSheet(LayoutMode.WRAP_CONTENT)).show {
customView(R.layout.fragment_playlist_details, scrollable = true)
}
playlistDetailsDialog.getCustomView().apply {
ivThumbnailPlaylistDetails.load(args.playlistThumbUrl)
tvNamePlaylistDetails.text = args.playlistName
tvVideoCountPlaylistDetails.text = context.resources.getQuantityString(
R.plurals.text_playlist_video_count,
args.playlistVideoCount.toInt(),
args.playlistVideoCount.toInt()
)
tvTimePublishedPlaylistDetails.text = context.getString(
R.string.text_playlist_published_date,
DateTimeUtils.getPublishedDate(args.playlistPublishedTime)
)
tvDescPlaylistDetails.text = args.playlistDesc
}
}
private fun sharePlaylistUrl() {
context?.startShareTextIntent(
getString(R.string.text_share_playlist),
getString(R.string.text_playlist_share_url, args.playlistId)
)
}
}

View File

@ -0,0 +1,221 @@
package aculix.channelify.app.fragment
import aculix.channelify.app.R
import aculix.channelify.app.fastadapteritems.PlaylistItem
import aculix.channelify.app.fastadapteritems.ProgressIndicatorItem
import aculix.channelify.app.model.Playlist
import aculix.channelify.app.paging.Status
import aculix.channelify.app.utils.DividerItemDecorator
import aculix.channelify.app.viewmodel.PlaylistsViewModel
import aculix.core.extensions.makeGone
import aculix.core.extensions.makeVisible
import aculix.core.extensions.openUrl
import android.os.Bundle
import android.view.View
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import androidx.navigation.fragment.findNavController
import androidx.paging.PagedList
import androidx.recyclerview.widget.AsyncDifferConfig
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.bottomnavigation.BottomNavigationView
import com.google.android.material.snackbar.Snackbar
import com.mikepenz.fastadapter.FastAdapter
import com.mikepenz.fastadapter.GenericFastAdapter
import com.mikepenz.fastadapter.adapters.GenericItemAdapter
import com.mikepenz.fastadapter.adapters.ItemAdapter
import com.mikepenz.fastadapter.paged.PagedModelAdapter
import kotlinx.android.synthetic.main.fragment_playlists.*
import kotlinx.android.synthetic.main.widget_toolbar.view.*
import org.koin.androidx.viewmodel.ext.android.viewModel
/**
* A simple [Fragment] subclass to show the list of
* Playlists of a channel.
*/
class PlaylistsFragment : Fragment(R.layout.fragment_playlists) {
private val viewModel by viewModel<PlaylistsViewModel>() // Lazy inject ViewModel
private lateinit var playlistsAdapter: GenericFastAdapter
private lateinit var playlistsPagedModelAdapter: PagedModelAdapter<Playlist.Item, PlaylistItem>
private lateinit var footerAdapter: GenericItemAdapter
private var isFirstPageLoading = true
private var retrySnackbar: Snackbar? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupToolbar()
setupRecyclerView(savedInstanceState)
fetchPlaylists()
setupObservables()
}
override fun onSaveInstanceState(_outState: Bundle) {
var outState = _outState
outState = playlistsAdapter.saveInstanceState(outState)
super.onSaveInstanceState(outState)
}
override fun onPause() {
super.onPause()
retrySnackbar?.dismiss() // Dismiss the retrySnackbar if already present
}
private fun setupToolbar() {
ablPlaylists.toolbarMain.apply {
inflateMenu(R.menu.main_menu)
// Store and Search configuration
menu.findItem(R.id.miStoreMainMenu).isVisible =
resources.getBoolean(R.bool.enable_store)
menu.findItem(R.id.miSearchMainMenu).isVisible =
resources.getBoolean(R.bool.enable_search)
setOnMenuItemClickListener { item ->
when (item.itemId) {
R.id.miStoreMainMenu -> {
context.openUrl(getString(R.string.store_url), R.color.defaultBgColor)
}
R.id.miSearchMainMenu -> {
findNavController().navigate(R.id.action_playlistsFragment_to_searchFragment)
}
}
false
}
}
}
private fun setupRecyclerView(savedInstanceState: Bundle?) {
val asyncDifferConfig = AsyncDifferConfig.Builder<Playlist.Item>(object :
DiffUtil.ItemCallback<Playlist.Item>() {
override fun areItemsTheSame(
oldItem: Playlist.Item,
newItem: Playlist.Item
): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(
oldItem: Playlist.Item,
newItem: Playlist.Item
): Boolean {
return oldItem == newItem
}
}).build()
playlistsPagedModelAdapter =
PagedModelAdapter<Playlist.Item, PlaylistItem>(asyncDifferConfig) {
PlaylistItem(it)
}
footerAdapter = ItemAdapter.items()
playlistsAdapter = FastAdapter.with(listOf(playlistsPagedModelAdapter, footerAdapter))
playlistsAdapter.registerTypeInstance(PlaylistItem(null))
playlistsAdapter.withSavedInstanceState(savedInstanceState)
rvPlaylists.layoutManager = LinearLayoutManager(context)
rvPlaylists.addItemDecoration(
DividerItemDecorator(
ContextCompat.getDrawable(
requireContext(),
R.drawable.view_divider_item_decorator
)!!
)
)
rvPlaylists.adapter = playlistsAdapter
onItemClick()
}
private fun setupObservables() {
// Observe Empty State LiveData
viewModel.emptyStateLiveData.observe(viewLifecycleOwner, Observer { isResultEmpty ->
if (isResultEmpty) {
showEmptyState()
} else {
hideEmptyState()
}
})
// Observe network live data
viewModel.networkStateLiveData?.observe(viewLifecycleOwner, Observer { networkState ->
when (networkState?.status) {
Status.FAILED -> {
footerAdapter.clear()
pbPlaylists.makeGone()
createRetrySnackbar()
retrySnackbar?.show()
}
Status.SUCCESS -> {
footerAdapter.clear()
pbPlaylists.makeGone()
}
Status.LOADING -> {
if (!isFirstPageLoading) {
showRecyclerViewProgressIndicator()
} else {
isFirstPageLoading = false
}
}
}
})
// Observe latest video live data
viewModel.playlistsLiveData?.observe(
viewLifecycleOwner,
Observer<PagedList<Playlist.Item>> { playlistsList ->
playlistsPagedModelAdapter.submitList(playlistsList)
})
}
private fun showRecyclerViewProgressIndicator() {
footerAdapter.clear()
val progressIndicatorItem = ProgressIndicatorItem()
footerAdapter.add(progressIndicatorItem)
}
private fun showEmptyState() {
groupEmptyPlaylists.makeVisible()
}
private fun hideEmptyState() {
groupEmptyPlaylists.makeGone()
}
private fun fetchPlaylists() {
viewModel.getPlaylists()
}
private fun createRetrySnackbar() {
retrySnackbar =
Snackbar.make(clPlaylists, R.string.error_fetch_playlists, Snackbar.LENGTH_INDEFINITE)
.setAnchorView(activity?.findViewById(R.id.bottomNavView) as BottomNavigationView)
.setAction(R.string.btn_retry) {
viewModel.refreshFailedRequest()
}
}
private fun onItemClick() {
playlistsAdapter.onClickListener = { view, adapter, item, position ->
if (item is PlaylistItem) {
val action =
PlaylistsFragmentDirections.actionPlaylistsFragmentToPlaylistVideosFragment(
item.playlistItem?.snippet?.title!!,
item.playlistItem.id,
item.playlistItem.snippet.description,
item.playlistItem.contentDetails.itemCount.toFloat(),
item.playlistItem.snippet.thumbnails.standard?.url
?: item.playlistItem.snippet.thumbnails.high.url,
item.playlistItem.snippet.publishedAt
)
findNavController().navigate(action)
}
false
}
}
}

View File

@ -0,0 +1,244 @@
package aculix.channelify.app.fragment
import aculix.channelify.app.R
import aculix.channelify.app.activity.VideoPlayerActivity
import aculix.channelify.app.fastadapteritems.ProgressIndicatorItem
import aculix.channelify.app.fastadapteritems.SearchItem
import aculix.channelify.app.model.SearchedVideo
import aculix.channelify.app.paging.Status
import aculix.channelify.app.utils.DividerItemDecorator
import aculix.channelify.app.viewmodel.SearchViewModel
import aculix.core.extensions.dismissKeyboard
import aculix.core.extensions.makeGone
import aculix.core.extensions.makeVisible
import aculix.core.extensions.showKeyboard
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.SearchView
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import androidx.navigation.fragment.findNavController
import androidx.navigation.ui.AppBarConfiguration
import androidx.navigation.ui.setupWithNavController
import androidx.paging.PagedList
import androidx.recyclerview.widget.AsyncDifferConfig
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.bottomnavigation.BottomNavigationView
import com.google.android.material.snackbar.Snackbar
import com.mikepenz.fastadapter.FastAdapter
import com.mikepenz.fastadapter.GenericFastAdapter
import com.mikepenz.fastadapter.adapters.GenericItemAdapter
import com.mikepenz.fastadapter.adapters.ItemAdapter
import com.mikepenz.fastadapter.paged.PagedModelAdapter
import kotlinx.android.synthetic.main.fragment_search.*
import org.koin.androidx.viewmodel.ext.android.viewModel
class SearchFragment : Fragment() {
private val viewModel by viewModel<SearchViewModel>() // Lazy inject ViewModel
private lateinit var searchAdapter: GenericFastAdapter
private lateinit var searchPagedModelAdapter: PagedModelAdapter<SearchedVideo.Item, SearchItem>
private lateinit var footerAdapter: GenericItemAdapter
private var isFirstPageLoading = true
private var retrySnackbar: Snackbar? = null
private var isSearchRequestInitialized = false
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
// Inflate the layout for this fragment
return inflater.inflate(R.layout.fragment_search, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupToolbar()
setupSearchView()
setupRecyclerView(savedInstanceState)
}
override fun onSaveInstanceState(_outState: Bundle) {
var outState = _outState
outState = searchAdapter.saveInstanceState(outState)
super.onSaveInstanceState(outState)
}
override fun onPause() {
super.onPause()
svSearch.dismissKeyboard(context)
retrySnackbar?.dismiss() // Dismiss the retrySnackbar if already present
}
private fun setupToolbar() {
val navController = findNavController()
val appBarConfiguration = AppBarConfiguration(navController.graph)
toolbarSearch.setupWithNavController(navController, appBarConfiguration)
}
private fun setupSearchView() {
svSearch.apply {
setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String): Boolean {
svSearch.dismissKeyboard(context)
if (query.isNotBlank()) {
pbSearch.makeVisible()
if (!isSearchRequestInitialized) {
isSearchRequestInitialized = true
viewModel.searchVideos(query)
setupObservables() // A bug arises and create() of DataSourceFactory is not called if observables are set before making an initial call :|
} else {
viewModel.setSearchQuery(query)
}
}
return true
}
override fun onQueryTextChange(p0: String?): Boolean {
return true
}
})
// Set focus on the SearchView and open the keyboard
setOnQueryTextFocusChangeListener { view, hasFocus ->
if (hasFocus) {
svSearch.findFocus().showKeyboard(context)
}
}
requestFocus()
}
}
private fun setupRecyclerView(savedInstanceState: Bundle?) {
val asyncDifferConfig = AsyncDifferConfig.Builder<SearchedVideo.Item>(object :
DiffUtil.ItemCallback<SearchedVideo.Item>() {
override fun areItemsTheSame(
oldItem: SearchedVideo.Item,
newItem: SearchedVideo.Item
): Boolean {
return oldItem.id.videoId == newItem.id.videoId
}
override fun areContentsTheSame(
oldItem: SearchedVideo.Item,
newItem: SearchedVideo.Item
): Boolean {
return oldItem == newItem
}
}).build()
searchPagedModelAdapter =
PagedModelAdapter<SearchedVideo.Item, SearchItem>(asyncDifferConfig) {
SearchItem(it)
}
footerAdapter = ItemAdapter.items()
searchAdapter = FastAdapter.with(listOf(searchPagedModelAdapter, footerAdapter))
searchAdapter.registerTypeInstance(SearchItem(null))
searchAdapter.withSavedInstanceState(savedInstanceState)
rvSearch.layoutManager = LinearLayoutManager(context)
rvSearch.addItemDecoration(
DividerItemDecorator(
ContextCompat.getDrawable(
requireContext(),
R.drawable.view_divider_item_decorator
)!!
)
)
rvSearch.adapter = searchAdapter
onItemClick()
}
private fun setupObservables() {
// Observe Empty State LiveData
viewModel.emptyStateLiveData.observe(viewLifecycleOwner, Observer { isResultEmpty ->
if (isResultEmpty) {
// True as no pages are loaded. If not done two loaders are shown when searched again.
isFirstPageLoading = true
showEmptyState()
} else {
hideEmptyState()
}
})
// Observe network live data
viewModel.networkStateLiveData?.observe(viewLifecycleOwner, Observer { networkState ->
when (networkState?.status) {
Status.FAILED -> {
footerAdapter.clear()
pbSearch.makeGone()
createRetrySnackbar()
retrySnackbar?.show()
}
Status.SUCCESS -> {
footerAdapter.clear()
pbSearch.makeGone()
hideEmptyState()
}
Status.LOADING -> {
if (!isFirstPageLoading) {
showRecyclerViewProgressIndicator()
} else {
isFirstPageLoading = false
}
}
}
})
// Observe latest video live data
viewModel.searchResultLiveData?.observe(
viewLifecycleOwner,
Observer<PagedList<SearchedVideo.Item>> { videoList ->
searchPagedModelAdapter.submitList(videoList)
})
}
private fun showRecyclerViewProgressIndicator() {
footerAdapter.clear()
val progressIndicatorItem = ProgressIndicatorItem()
footerAdapter.add(progressIndicatorItem)
}
private fun createRetrySnackbar() {
retrySnackbar =
Snackbar.make(clSearch, R.string.error_load_more_videos, Snackbar.LENGTH_INDEFINITE)
.setAnchorView(activity?.findViewById(R.id.bottomNavView) as BottomNavigationView)
.setAction(R.string.btn_retry) {
viewModel.refreshFailedRequest()
}
}
private fun showEmptyState() {
groupEmptySearch.makeVisible()
}
private fun hideEmptyState() {
groupEmptySearch.makeGone()
}
/**
* Called when an item of the RecyclerView is clicked
*/
private fun onItemClick() {
searchAdapter.onClickListener = { view, adapter, item, position ->
if (item is SearchItem) {
VideoPlayerActivity.startActivity(context, item.searchedVideo?.id?.videoId!!)
}
false
}
}
}

View File

@ -0,0 +1,213 @@
package aculix.channelify.app.fragment
import aculix.channelify.app.R
import aculix.channelify.app.model.FavoriteVideo
import aculix.channelify.app.model.Video
import aculix.channelify.app.utils.DateTimeUtils
import aculix.channelify.app.viewmodel.VideoDetailsViewModel
import aculix.core.extensions.*
import aculix.core.helper.ResultWrapper
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import kotlinx.android.synthetic.main.fragment_video_details.*
import org.koin.androidx.viewmodel.ext.android.viewModel
class VideoDetailsFragment : Fragment() {
companion object {
const val VIDEO_ID = "videoId"
}
private val viewModel by viewModel<VideoDetailsViewModel>() // Lazy inject ViewModel
private val args by navArgs<VideoDetailsFragmentArgs>()
private lateinit var videoId: String
private lateinit var videoItem: Video.Item
private var isVideoAddedToFavorite = false
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
// Inflate the layout for this fragment
return inflater.inflate(R.layout.fragment_video_details, container, false)
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
setupObservables()
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
videoId = args.videoId
fetchVideoInfo()
fetchVideoFavoriteStatus()
setupBottomAppBar()
tvCommentsVideoDetails.setOnClickListener { onCommentsClick() }
btnRetryVideoDetails.setOnClickListener { onRetryClick() }
}
/**
* Fetches the info of video
*/
private fun fetchVideoInfo() {
if (isInternetAvailable(requireContext())) {
viewModel.getVideoInfo(videoId)
} else {
showVideoInfoErrorState()
}
}
/**
* Checks whether the current playing video is already added to favorites or not
*/
private fun fetchVideoFavoriteStatus() {
viewModel.getVideoFavoriteStatus(videoId)
}
private fun setupObservables() {
viewModel.videoInfoLiveData.observe(viewLifecycleOwner, Observer {
when (it) {
is ResultWrapper.Error -> {
showVideoInfoErrorState()
}
is ResultWrapper.Success<*> -> {
hideVideoInfoErrorState()
videoItem = (it.data as Video).items[0]
setVideoInfo()
}
}
})
viewModel.favoriteVideoLiveData.observe(viewLifecycleOwner, Observer { isFavorite ->
isVideoAddedToFavorite = isFavorite
if (isVideoAddedToFavorite) {
babVideoDetails.menu.findItem(R.id.miFavoriteBabVideoDetails)
.setIcon(R.drawable.ic_favorite_filled)
} else {
babVideoDetails.menu.findItem(R.id.miFavoriteBabVideoDetails)
.setIcon(R.drawable.ic_favorite_border)
}
})
}
private fun setVideoInfo() {
with(videoItem) {
tvVideoTitleVideoDetails.text = snippet.title
tvViewCountVideoDetails.text =
statistics.viewCount?.toLong()?.getFormattedNumberInString() ?: getString(
R.string.text_count_unavailable
)
tvLikeCountVideoDetails.text =
statistics.likeCount?.toLong()?.getFormattedNumberInString() ?: getString(
R.string.text_count_unavailable
)
tvDislikeCountVideoDetails.text =
statistics.dislikeCount?.toLong()?.getFormattedNumberInString() ?: getString(
R.string.text_count_unavailable
)
tvCommentCountVideoDetails.text =
statistics.commentCount.toLong().getFormattedNumberInString()
tvVideoDescVideoDetails.text = getString(
R.string.text_video_description,
DateTimeUtils.getPublishedDate(snippet.publishedAt),
snippet.description
)
}
}
private fun showVideoInfoErrorState() {
groupInfoVideoDetails.makeGone()
groupErrorVideoDetails.makeVisible()
}
private fun hideVideoInfoErrorState() {
groupErrorVideoDetails.makeGone()
groupInfoVideoDetails.makeVisible()
}
private fun setupBottomAppBar() {
babVideoDetails.setOnMenuItemClickListener { item ->
when (item.itemId) {
R.id.miFavoriteBabVideoDetails -> {
if (this::videoItem.isInitialized) {
// Add or remove from favorites only after videoItem details are fetched
isVideoAddedToFavorite = if (isVideoAddedToFavorite) {
item.setIcon(R.drawable.ic_favorite_border)
removeVideoFromFavorites()
false
} else {
// Add video to favorites
item.setIcon(R.drawable.ic_favorite_filled)
addVideoToFavorites()
true
}
} else {
context?.toast(getString(R.string.text_fetch_video_details_wait_msg))
}
true
}
R.id.miShareBabVideoDetails -> {
shareVideoUrl()
true
}
else -> false
}
}
}
private fun addVideoToFavorites() {
val favoriteVideo = FavoriteVideo(
videoId,
videoItem.snippet.title,
videoItem.snippet.thumbnails.standard?.url ?: videoItem.snippet.thumbnails.high.url,
System.currentTimeMillis(),
true
)
viewModel.addVideoToFavorites(favoriteVideo)
}
private fun removeVideoFromFavorites() {
val favoriteVideo = FavoriteVideo(
videoId,
videoItem.snippet.title,
videoItem.snippet.thumbnails.standard?.url ?: videoItem.snippet.thumbnails.high.url,
System.currentTimeMillis(),
true
)
viewModel.removeVideoFromFavorites(favoriteVideo)
}
private fun shareVideoUrl() {
context?.startShareTextIntent(
getString(R.string.text_share_video),
getString(R.string.text_video_share_url, videoId)
)
}
private fun onCommentsClick() {
findNavController().navigate(
VideoDetailsFragmentDirections.actionVideoDetailsFragmentToCommentsFragment(
videoId
)
)
}
private fun onRetryClick() {
fetchVideoInfo()
}
}

View File

@ -0,0 +1,58 @@
package aculix.channelify.app.model
data class ChannelInfo(
val items: List<Item>
) {
data class Item(
val brandingSettings: BrandingSettings?,
val snippet: Snippet,
val statistics: Statistics
) {
data class BrandingSettings(
val image: Image?
) {
data class Image(
val bannerMobileHdImageUrl: String?,
val bannerMobileMediumHdImageUrl: String
)
}
data class Snippet(
val description: String,
val publishedAt: String,
val thumbnails: Thumbnails,
val title: String
) {
data class Thumbnails(
val default: Default,
val high: High?,
val medium: Medium
) {
data class Default(
val height: Int,
val url: String,
val width: Int
)
data class High(
val height: Int,
val url: String,
val width: Int
)
data class Medium(
val height: Int,
val url: String,
val width: Int
)
}
}
data class Statistics(
val subscriberCount: String,
val videoCount: String,
val viewCount: String
)
}
}

View File

@ -0,0 +1,26 @@
package aculix.channelify.app.model
import com.google.gson.annotations.SerializedName
/**
* Model class that is returned when an API call is made to
* request details about the Uploads playlist of the channel
*
* URL: https://www.googleapis.com/youtube/v3/channels
*/
data class ChannelUploadsPlaylistInfo(
val items: List<Item>
) {
data class Item(
val contentDetails: ContentDetails
) {
data class ContentDetails(
val relatedPlaylists: RelatedPlaylists
) {
data class RelatedPlaylists(
val uploads: String
)
}
}
}

View File

@ -0,0 +1,32 @@
package aculix.channelify.app.model
import com.google.gson.annotations.SerializedName
data class Comment(
val items: List<Item>,
val nextPageToken: String?
) {
data class Item(
val snippet: Snippet
) {
data class Snippet(
val topLevelComment: TopLevelComment,
val totalReplyCount: Int
) {
data class TopLevelComment(
val id: String,
val snippet: Snippet
) {
data class Snippet(
val authorDisplayName: String,
val authorProfileImageUrl: String,
val likeCount: Int,
val publishedAt: String,
val textOriginal: String,
val updatedAt: String
)
}
}
}
}

View File

@ -0,0 +1,23 @@
package aculix.channelify.app.model
import com.google.gson.annotations.SerializedName
data class CommentReply(
val items: List<Item>,
val nextPageToken: String?
) {
data class Item(
val id: String,
val snippet: Snippet
) {
data class Snippet(
val authorDisplayName: String,
val authorProfileImageUrl: String,
val likeCount: Int,
val publishedAt: String,
val textOriginal: String,
val updatedAt: String
)
}
}

View File

@ -0,0 +1,11 @@
package aculix.channelify.app.model
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "favorite_videos")
data class FavoriteVideo(@PrimaryKey val id: String,
val title: String,
val thumbnail: String,
val timeInMillis: Long,
var isChecked: Boolean)

View File

@ -0,0 +1,57 @@
package aculix.channelify.app.model
import com.google.gson.annotations.SerializedName
data class Playlist(
val items: List<Item>,
val nextPageToken: String
) {
data class Item(
val contentDetails: ContentDetails,
val id: String,
val snippet: Snippet
) {
data class ContentDetails(
val itemCount: Int
)
data class Snippet(
val description: String,
val publishedAt: String,
val thumbnails: Thumbnails,
val title: String
) {
data class Thumbnails(
val default: Default,
val high: High,
val medium: Medium,
val standard: Standard?
) {
data class Default(
val height: Int,
val url: String,
val width: Int
)
data class High(
val height: Int,
val url: String,
val width: Int
)
data class Medium(
val height: Int,
val url: String,
val width: Int
)
data class Standard(
val height: Int,
val url: String,
val width: Int
)
}
}
}
}

View File

@ -0,0 +1,63 @@
package aculix.channelify.app.model
import com.google.gson.annotations.SerializedName
data class PlaylistItemInfo(
val nextPageToken: String?,
val prevPageToken: String?,
val items: List<Item>
) {
data class Item(
val contentDetails: ContentDetails,
val snippet: Snippet
) {
data class ContentDetails(
val videoId: String,
val videoPublishedAt: String
)
data class Snippet(
val thumbnails: Thumbnails,
val title: String
) {
data class Thumbnails(
val default: Default,
val high: High,
val maxres: Maxres,
val medium: Medium,
val standard: Standard?
) {
data class Default(
val height: Int,
val url: String,
val width: Int
)
data class High(
val height: Int,
val url: String,
val width: Int
)
data class Maxres(
val height: Int,
val url: String,
val width: Int
)
data class Medium(
val height: Int,
val url: String,
val width: Int
)
data class Standard(
val height: Int,
val url: String,
val width: Int
)
}
}
}
}

View File

@ -0,0 +1,59 @@
package aculix.channelify.app.model
/**
* Model class that is returned when a Video Search API call is made.
*
* URL: https://www.googleapis.com/youtube/v3/search
*/
data class SearchedVideo(
val items: List<Item>,
val nextPageToken: String?
) {
data class Item(
val id: Id,
val snippet: Snippet
) {
data class Id(
val videoId: String
)
data class Snippet(
val publishedAt: String,
val thumbnails: Thumbnails,
val title: String
) {
data class Thumbnails(
val default: Default,
val high: High,
val medium: Medium,
val standard: Standard?
) {
data class Default(
val height: Int,
val url: String,
val width: Int
)
data class High(
val height: Int,
val url: String,
val width: Int
)
data class Medium(
val height: Int,
val url: String,
val width: Int
)
data class Standard(
val height: Int,
val url: String,
val width: Int
)
}
}
}
}

View File

@ -0,0 +1,67 @@
package aculix.channelify.app.model
import com.google.gson.annotations.SerializedName
data class Video(
val items: List<Item>
) {
data class Item(
val snippet: Snippet,
val statistics: Statistics
) {
data class Snippet(
val description: String,
val publishedAt: String,
val title: String,
val thumbnails: Thumbnails
) {
data class Thumbnails(
val default: Default,
val high: High,
val maxres: Maxres,
val medium: Medium,
val standard: Standard?
) {
data class Default(
val height: Int,
val url: String,
val width: Int
)
data class High(
val height: Int,
val url: String,
val width: Int
)
data class Maxres(
val height: Int,
val url: String,
val width: Int
)
data class Medium(
val height: Int,
val url: String,
val width: Int
)
data class Standard(
val height: Int,
val url: String,
val width: Int
)
}
}
data class Statistics(
val commentCount: String,
val dislikeCount: String?,
val favoriteCount: String,
val likeCount: String?,
val viewCount: String?
)
}
}

View File

@ -0,0 +1,20 @@
package aculix.channelify.app.paging
/**
* Class used to handle network state
*/
data class NetworkState constructor(val status: Status,
val msg: String? = null) {
companion object {
val LOADED = NetworkState(Status.SUCCESS)
val LOADING = NetworkState(Status.LOADING)
fun error(msg: String?) = NetworkState(Status.FAILED, msg)
}
}
enum class Status {
LOADING,
SUCCESS,
FAILED
}

View File

@ -0,0 +1,87 @@
package aculix.channelify.app.paging.datasource
import aculix.channelify.app.model.Comment
import aculix.channelify.app.model.CommentReply
import aculix.channelify.app.paging.NetworkState
import aculix.channelify.app.repository.CommentRepliesRepository
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.paging.PageKeyedDataSource
import kotlinx.coroutines.*
import timber.log.Timber
class CommentRepliesDataSource(
private val commentRepliesRepository: CommentRepliesRepository,
private val coroutineScope: CoroutineScope,
private val commentId: String
) : PageKeyedDataSource<String, CommentReply.Item>() {
private var supervisorJob = SupervisorJob()
private val networkState = MutableLiveData<NetworkState>()
private var retryQuery: (() -> Any)? = null // Keep reference of the last query (to be able to retry it if necessary)
private var nextPageToken: String? = null
override fun loadInitial(
params: LoadInitialParams<String>,
callback: LoadInitialCallback<String, CommentReply.Item>
) {
retryQuery = { loadInitial(params, callback) }
executeQuery {
callback.onResult(it, null, nextPageToken)
}
}
override fun loadAfter(
params: LoadParams<String>,
callback: LoadCallback<String, CommentReply.Item>
) {
retryQuery = { loadAfter(params, callback) }
executeQuery {
callback.onResult(it, nextPageToken)
}
}
override fun loadBefore(
params: LoadParams<String>,
callback: LoadCallback<String, CommentReply.Item>
) {
// Data is always fetched from the next page and hence loadBefore is never needed
}
private fun executeQuery(callback: (List<CommentReply.Item>) -> Unit) {
networkState.postValue(NetworkState.LOADING)
coroutineScope.launch(getJobErrorHandler() + supervisorJob) {
val commentReply = commentRepliesRepository.getCommentReplies(commentId, nextPageToken).body()
nextPageToken = commentReply?.nextPageToken
val commentRepliesList = commentReply?.items
retryQuery = null
networkState.postValue(NetworkState.LOADED)
callback(commentRepliesList ?: emptyList())
}
}
private fun getJobErrorHandler() = CoroutineExceptionHandler { _, e ->
Timber.e("An error happened: $e")
networkState.postValue(
NetworkState.error(
e.localizedMessage
)
)
}
override fun invalidate() {
super.invalidate()
supervisorJob.cancelChildren() // Cancel possible running job to only keep last result searched by user
}
fun getNetworkState(): LiveData<NetworkState> = networkState
fun refresh() = this.invalidate()
fun retryFailedQuery() {
val prevQuery = retryQuery
retryQuery = null
prevQuery?.invoke()
}
}

View File

@ -0,0 +1,87 @@
package aculix.channelify.app.paging.datasource
import aculix.channelify.app.model.Comment
import aculix.channelify.app.paging.NetworkState
import aculix.channelify.app.repository.CommentsRepository
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.paging.PageKeyedDataSource
import kotlinx.coroutines.*
import timber.log.Timber
class CommentsDataSource(
private val commentsRepository: CommentsRepository,
private val coroutineScope: CoroutineScope,
private val videoId: String,
var sortOrder: String
) : PageKeyedDataSource<String, Comment.Item>() {
private var supervisorJob = SupervisorJob()
private val networkState = MutableLiveData<NetworkState>()
private var retryQuery: (() -> Any)? = null // Keep reference of the last query (to be able to retry it if necessary)
private var nextPageToken: String? = null
override fun loadInitial(
params: LoadInitialParams<String>,
callback: LoadInitialCallback<String, Comment.Item>
) {
retryQuery = { loadInitial(params, callback) }
executeQuery {
callback.onResult(it, null, nextPageToken)
}
}
override fun loadAfter(
params: LoadParams<String>,
callback: LoadCallback<String, Comment.Item>
) {
retryQuery = { loadAfter(params, callback) }
executeQuery {
callback.onResult(it, nextPageToken)
}
}
override fun loadBefore(
params: LoadParams<String>,
callback: LoadCallback<String, Comment.Item>
) {
// Data is always fetched from the next page and hence loadBefore is never needed
}
private fun executeQuery(callback: (List<Comment.Item>) -> Unit) {
networkState.postValue(NetworkState.LOADING)
coroutineScope.launch(getJobErrorHandler() + supervisorJob) {
val comment = commentsRepository.getVideoComments(videoId, nextPageToken, sortOrder).body()
nextPageToken = comment?.nextPageToken
val commentsList = comment?.items
retryQuery = null
networkState.postValue(NetworkState.LOADED)
callback(commentsList ?: emptyList())
}
}
private fun getJobErrorHandler() = CoroutineExceptionHandler { _, e ->
Timber.e("An error happened: $e")
networkState.postValue(
NetworkState.error(
e.localizedMessage
)
)
}
override fun invalidate() {
super.invalidate()
supervisorJob.cancelChildren() // Cancel possible running job to only keep last result searched by user
}
fun getNetworkState(): LiveData<NetworkState> = networkState
fun refresh() = this.invalidate()
fun retryFailedQuery() {
val prevQuery = retryQuery
retryQuery = null
prevQuery?.invoke()
}
}

View File

@ -0,0 +1,87 @@
package aculix.channelify.app.paging.datasource
import aculix.channelify.app.model.PlaylistItemInfo
import aculix.channelify.app.paging.NetworkState
import aculix.channelify.app.repository.HomeRepository
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.paging.PageKeyedDataSource
import kotlinx.coroutines.*
import timber.log.Timber
class HomeDataSource(
private val homeRepository: HomeRepository,
private val coroutineScope: CoroutineScope,
private val playlistId: String
) : PageKeyedDataSource<String, PlaylistItemInfo.Item>() {
private var supervisorJob = SupervisorJob()
private val networkState = MutableLiveData<NetworkState>()
private var retryQuery: (() -> Any)? = null // Keep reference of the last query (to be able to retry it if necessary)
private var nextPageToken: String? = null
override fun loadInitial(
params: LoadInitialParams<String>,
callback: LoadInitialCallback<String, PlaylistItemInfo.Item>
) {
retryQuery = { loadInitial(params, callback) }
executeQuery {
callback.onResult(it, null, nextPageToken)
}
}
override fun loadAfter(
params: LoadParams<String>,
callback: LoadCallback<String, PlaylistItemInfo.Item>
) {
retryQuery = { loadAfter(params, callback) }
executeQuery {
callback.onResult(it, nextPageToken)
}
}
override fun loadBefore(
params: LoadParams<String>,
callback: LoadCallback<String, PlaylistItemInfo.Item>
) {
// Data is always fetched from the next page and hence loadBefore is never needed
}
private fun executeQuery(callback: (List<PlaylistItemInfo.Item>) -> Unit) {
networkState.postValue(NetworkState.LOADING)
coroutineScope.launch(getJobErrorHandler() + supervisorJob) {
val playlistItemInfo = homeRepository.getLatestVideos(playlistId, nextPageToken).body()
nextPageToken = playlistItemInfo?.nextPageToken
val latestVideosList = playlistItemInfo?.items
retryQuery = null
networkState.postValue(NetworkState.LOADED)
callback(latestVideosList ?: emptyList())
}
}
private fun getJobErrorHandler() = CoroutineExceptionHandler { _, e ->
Timber.e("An error happened: $e")
networkState.postValue(
NetworkState.error(
e.localizedMessage
)
)
}
override fun invalidate() {
super.invalidate()
supervisorJob.cancelChildren() // Cancel possible running job to only keep last result searched by user
}
fun getNetworkState(): LiveData<NetworkState> = networkState
fun refresh() = this.invalidate()
fun retryFailedQuery() {
val prevQuery = retryQuery
retryQuery = null
prevQuery?.invoke()
}
}

View File

@ -0,0 +1,87 @@
package aculix.channelify.app.paging.datasource
import aculix.channelify.app.model.PlaylistItemInfo
import aculix.channelify.app.paging.NetworkState
import aculix.channelify.app.repository.PlaylistVideosRepository
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.paging.PageKeyedDataSource
import kotlinx.coroutines.*
import timber.log.Timber
class PlaylistVideosDataSource(
private val repository: PlaylistVideosRepository,
private val coroutineScope: CoroutineScope,
private val playlistId: String
) : PageKeyedDataSource<String, PlaylistItemInfo.Item>() {
private var supervisorJob = SupervisorJob()
private val networkState = MutableLiveData<NetworkState>()
private var retryQuery: (() -> Any)? = null // Keep reference of the last query (to be able to retry it if necessary)
private var nextPageToken: String? = null
override fun loadInitial(
params: LoadInitialParams<String>,
callback: LoadInitialCallback<String, PlaylistItemInfo.Item>
) {
retryQuery = { loadInitial(params, callback) }
executeQuery {
callback.onResult(it, null, nextPageToken)
}
}
override fun loadAfter(
params: LoadParams<String>,
callback: LoadCallback<String, PlaylistItemInfo.Item>
) {
retryQuery = { loadAfter(params, callback) }
executeQuery {
callback.onResult(it, nextPageToken)
}
}
override fun loadBefore(
params: LoadParams<String>,
callback: LoadCallback<String, PlaylistItemInfo.Item>
) {
// Data is always fetched from the next page and hence loadBefore is never needed
}
private fun executeQuery(callback: (List<PlaylistItemInfo.Item>) -> Unit) {
networkState.postValue(NetworkState.LOADING)
coroutineScope.launch(getJobErrorHandler() + supervisorJob) {
val playlistItemInfo = repository.getPlaylistVideos(playlistId, nextPageToken).body()
nextPageToken = playlistItemInfo?.nextPageToken
val playlistVideosList = playlistItemInfo?.items
retryQuery = null
networkState.postValue(NetworkState.LOADED)
callback(playlistVideosList ?: emptyList())
}
}
private fun getJobErrorHandler() = CoroutineExceptionHandler { _, e ->
Timber.e("An error happened: $e")
networkState.postValue(
NetworkState.error(
e.localizedMessage
)
)
}
override fun invalidate() {
super.invalidate()
supervisorJob.cancelChildren() // Cancel possible running job to only keep last result searched by user
}
fun getNetworkState(): LiveData<NetworkState> = networkState
fun refresh() = this.invalidate()
fun retryFailedQuery() {
val prevQuery = retryQuery
retryQuery = null
prevQuery?.invoke()
}
}

View File

@ -0,0 +1,87 @@
package aculix.channelify.app.paging.datasource
import aculix.channelify.app.model.Playlist
import aculix.channelify.app.paging.NetworkState
import aculix.channelify.app.repository.PlaylistsRepository
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.paging.PageKeyedDataSource
import kotlinx.coroutines.*
import timber.log.Timber
class PlaylistsDataSource(
private val repository: PlaylistsRepository,
private val coroutineScope: CoroutineScope,
private val channelId: String
) : PageKeyedDataSource<String, Playlist.Item>() {
private var supervisorJob = SupervisorJob()
private val networkState = MutableLiveData<NetworkState>()
private var retryQuery: (() -> Any)? = null // Keep reference of the last query (to be able to retry it if necessary)
private var nextPageToken: String? = null
override fun loadInitial(
params: LoadInitialParams<String>,
callback: LoadInitialCallback<String, Playlist.Item>
) {
retryQuery = { loadInitial(params, callback) }
executeQuery {
callback.onResult(it, null, nextPageToken)
}
}
override fun loadAfter(
params: LoadParams<String>,
callback: LoadCallback<String, Playlist.Item>
) {
retryQuery = { loadAfter(params, callback) }
executeQuery {
callback.onResult(it, nextPageToken)
}
}
override fun loadBefore(
params: LoadParams<String>,
callback: LoadCallback<String, Playlist.Item>
) {
// Data is always fetched from the next page and hence loadBefore is never needed
}
private fun executeQuery(callback: (List<Playlist.Item>) -> Unit) {
networkState.postValue(NetworkState.LOADING)
coroutineScope.launch(getJobErrorHandler() + supervisorJob) {
val response = repository.getPlaylists(channelId, nextPageToken).body()
nextPageToken = response?.nextPageToken
val playlistList = response?.items
retryQuery = null
networkState.postValue(NetworkState.LOADED)
callback(playlistList ?: emptyList())
}
}
private fun getJobErrorHandler() = CoroutineExceptionHandler { _, e ->
Timber.e("An error happened: $e")
networkState.postValue(
NetworkState.error(
e.localizedMessage
)
)
}
override fun invalidate() {
super.invalidate()
supervisorJob.cancelChildren() // Cancel possible running job to only keep last result searched by user
}
fun getNetworkState(): LiveData<NetworkState> = networkState
fun refresh() = this.invalidate()
fun retryFailedQuery() {
val prevQuery = retryQuery
retryQuery = null
prevQuery?.invoke()
}
}

View File

@ -0,0 +1,90 @@
package aculix.channelify.app.paging.datasource
import aculix.channelify.app.model.SearchedVideo
import aculix.channelify.app.paging.NetworkState
import aculix.channelify.app.repository.SearchRepository
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.paging.PageKeyedDataSource
import kotlinx.coroutines.*
import timber.log.Timber
class SearchDataSource(
private val searchRepository: SearchRepository,
private val coroutineScope: CoroutineScope,
private val channelId: String,
private var searchQuery: String,
private val emptySearchResultText: String
) : PageKeyedDataSource<String, SearchedVideo.Item>() {
private var supervisorJob = SupervisorJob()
private val networkState = MutableLiveData<NetworkState>()
private var retryQuery: (() -> Any)? =
null // Keep reference of the last query (to be able to retry it if necessary)
private var nextPageToken: String? = null
override fun loadInitial(
params: LoadInitialParams<String>,
callback: LoadInitialCallback<String, SearchedVideo.Item>
) {
retryQuery = { loadInitial(params, callback) }
executeQuery {
callback.onResult(it, null, nextPageToken)
}
}
override fun loadAfter(
params: LoadParams<String>,
callback: LoadCallback<String, SearchedVideo.Item>
) {
retryQuery = { loadAfter(params, callback) }
executeQuery {
callback.onResult(it, nextPageToken)
}
}
override fun loadBefore(
params: LoadParams<String>,
callback: LoadCallback<String, SearchedVideo.Item>
) {
// Data is always fetched from the next page and hence loadBefore is never needed
}
private fun executeQuery(callback: (List<SearchedVideo.Item>) -> Unit) {
networkState.postValue(NetworkState.LOADING)
coroutineScope.launch(getJobErrorHandler() + supervisorJob) {
val searchVideoResult =
searchRepository.searchVideos(searchQuery, channelId, nextPageToken).body()
nextPageToken = searchVideoResult?.nextPageToken
val videoList = searchVideoResult?.items
retryQuery = null
networkState.postValue(NetworkState.LOADED)
callback(videoList ?: emptyList())
}
}
private fun getJobErrorHandler() = CoroutineExceptionHandler { _, e ->
Timber.e("An error happened: $e")
networkState.postValue(
NetworkState.error(
e.localizedMessage
)
)
}
override fun invalidate() {
super.invalidate()
supervisorJob.cancelChildren() // Cancel possible running job to only keep last result searched by user
}
fun getNetworkState(): LiveData<NetworkState> = networkState
fun refresh() = this.invalidate()
fun retryFailedQuery() {
val prevQuery = retryQuery
retryQuery = null
prevQuery?.invoke()
}
}

View File

@ -0,0 +1,33 @@
package aculix.channelify.app.paging.datasourcefactory
import aculix.channelify.app.model.CommentReply
import aculix.channelify.app.paging.datasource.CommentRepliesDataSource
import aculix.channelify.app.repository.CommentRepliesRepository
import androidx.lifecycle.MutableLiveData
import androidx.paging.DataSource
import kotlinx.coroutines.CoroutineScope
class CommentRepliesDataSourceFactory(
private val commentRepliesRepository: CommentRepliesRepository,
private val coroutineScope: CoroutineScope,
private val commentId: String
) : DataSource.Factory<String, CommentReply.Item>() {
private var commentRepliesDataSource: CommentRepliesDataSource? = null
val commentRepliesDataSourceLiveData = MutableLiveData<CommentRepliesDataSource>()
override fun create(): DataSource<String, CommentReply.Item> {
// Also called every time when invalidate() is executed
commentRepliesDataSource =
CommentRepliesDataSource(
commentRepliesRepository,
coroutineScope,
commentId
)
commentRepliesDataSourceLiveData.postValue(commentRepliesDataSource)
return commentRepliesDataSource!!
}
fun getSource() = commentRepliesDataSourceLiveData.value
}

View File

@ -0,0 +1,40 @@
package aculix.channelify.app.paging.datasourcefactory
import aculix.channelify.app.model.Comment
import aculix.channelify.app.paging.datasource.CommentsDataSource
import aculix.channelify.app.repository.CommentsRepository
import androidx.lifecycle.MutableLiveData
import androidx.paging.DataSource
import kotlinx.coroutines.CoroutineScope
import timber.log.Timber
class CommentsDataSourceFactory(
private val commentsRepository: CommentsRepository,
private val coroutineScope: CoroutineScope,
private val videoId: String,
private var sortOrder: String
) : DataSource.Factory<String, Comment.Item>() {
private var commentsDataSource: CommentsDataSource? = null
val commentsDataSourceLiveData = MutableLiveData<CommentsDataSource>()
override fun create(): DataSource<String, Comment.Item> {
// Also called every time when invalidate() is executed
commentsDataSource =
CommentsDataSource(
commentsRepository,
coroutineScope,
videoId,
sortOrder
)
commentsDataSourceLiveData.postValue(commentsDataSource)
return commentsDataSource!!
}
fun setSortOrder(updatedSortOrder: String) {
sortOrder = updatedSortOrder
}
fun getSource() = commentsDataSourceLiveData.value
}

View File

@ -0,0 +1,33 @@
package aculix.channelify.app.paging.datasourcefactory
import aculix.channelify.app.model.PlaylistItemInfo
import aculix.channelify.app.paging.datasource.HomeDataSource
import aculix.channelify.app.repository.HomeRepository
import androidx.lifecycle.MutableLiveData
import androidx.paging.DataSource
import kotlinx.coroutines.CoroutineScope
class HomeDataSourceFactory(
private val homeRepository: HomeRepository,
private val coroutineScope: CoroutineScope,
private val playlistId: String
) : DataSource.Factory<String, PlaylistItemInfo.Item>() {
private var homeDataSource: HomeDataSource? = null
val homeDataSourceLiveData = MutableLiveData<HomeDataSource>()
override fun create(): DataSource<String, PlaylistItemInfo.Item> {
if (homeDataSource == null) {
homeDataSource =
HomeDataSource(
homeRepository,
coroutineScope,
playlistId
)
homeDataSourceLiveData.postValue(homeDataSource)
}
return homeDataSource!!
}
fun getSource() = homeDataSourceLiveData.value
}

View File

@ -0,0 +1,33 @@
package aculix.channelify.app.paging.datasourcefactory
import aculix.channelify.app.model.PlaylistItemInfo
import aculix.channelify.app.paging.datasource.PlaylistVideosDataSource
import aculix.channelify.app.repository.PlaylistVideosRepository
import androidx.lifecycle.MutableLiveData
import androidx.paging.DataSource
import kotlinx.coroutines.CoroutineScope
class PlaylistVideosDataSourceFactory(
private val playlistVideosRepository: PlaylistVideosRepository,
private val coroutineScope: CoroutineScope,
private val playlistId: String
) : DataSource.Factory<String, PlaylistItemInfo.Item>() {
private var playlistVideosDataSource: PlaylistVideosDataSource? = null
val playlistVideosDataSourceLiveData = MutableLiveData<PlaylistVideosDataSource>()
override fun create(): DataSource<String, PlaylistItemInfo.Item> {
if (playlistVideosDataSource == null) {
playlistVideosDataSource =
PlaylistVideosDataSource(
playlistVideosRepository,
coroutineScope,
playlistId
)
playlistVideosDataSourceLiveData.postValue(playlistVideosDataSource)
}
return playlistVideosDataSource!!
}
fun getSource() = playlistVideosDataSourceLiveData.value
}

View File

@ -0,0 +1,33 @@
package aculix.channelify.app.paging.datasourcefactory
import aculix.channelify.app.model.Playlist
import aculix.channelify.app.paging.datasource.PlaylistsDataSource
import aculix.channelify.app.repository.PlaylistsRepository
import androidx.lifecycle.MutableLiveData
import androidx.paging.DataSource
import kotlinx.coroutines.CoroutineScope
class PlaylistsDataSourceFactory(
private val playlistRepository: PlaylistsRepository,
private val coroutineScope: CoroutineScope,
private val channelId: String
) : DataSource.Factory<String, Playlist.Item>() {
private var playlistsDataSource: PlaylistsDataSource? = null
val playlistsDataSourceLiveData = MutableLiveData<PlaylistsDataSource>()
override fun create(): DataSource<String, Playlist.Item> {
if (playlistsDataSource == null) {
playlistsDataSource =
PlaylistsDataSource(
playlistRepository,
coroutineScope,
channelId
)
playlistsDataSourceLiveData.postValue(playlistsDataSource)
}
return playlistsDataSource!!
}
fun getSource() = playlistsDataSourceLiveData.value
}

View File

@ -0,0 +1,42 @@
package aculix.channelify.app.paging.datasourcefactory
import aculix.channelify.app.model.SearchedVideo
import aculix.channelify.app.paging.datasource.SearchDataSource
import aculix.channelify.app.repository.SearchRepository
import androidx.lifecycle.MutableLiveData
import androidx.paging.DataSource
import kotlinx.coroutines.CoroutineScope
import timber.log.Timber
class SearchDataSourceFactory(
private val searchRepository: SearchRepository,
private val coroutineScope: CoroutineScope,
private val channelId: String,
private var searchQuery: String,
private val emptySearchResultText: String
) : DataSource.Factory<String, SearchedVideo.Item>() {
private var searchDataSource: SearchDataSource? = null
val searchDataSourceLiveData = MutableLiveData<SearchDataSource>()
override fun create(): DataSource<String, SearchedVideo.Item> {
// Also called every time when invalidate() is executed
searchDataSource =
SearchDataSource(
searchRepository,
coroutineScope,
channelId,
searchQuery,
emptySearchResultText
)
searchDataSourceLiveData.postValue(searchDataSource)
return searchDataSource!!
}
fun setSearchQuery(updatedSearchQuery: String) {
searchQuery = updatedSearchQuery
}
fun getSource() = searchDataSourceLiveData.value
}

View File

@ -0,0 +1,9 @@
package aculix.channelify.app.repository
import aculix.channelify.app.api.ChannelInfoService
class AboutRepository(private val channelInfoService: ChannelInfoService) {
suspend fun getChannelInfo(channelId: String) =
channelInfoService.getChannelInfo(channelId)
}

View File

@ -0,0 +1,13 @@
package aculix.channelify.app.repository
import aculix.channelify.app.api.CommentRepliesService
import aculix.channelify.app.api.CommentService
import aculix.channelify.app.model.Comment
import aculix.channelify.app.model.CommentReply
import retrofit2.Response
class CommentRepliesRepository(private val commentRepliesService: CommentRepliesService) {
suspend fun getCommentReplies(commentId: String, pageToken: String?): Response<CommentReply> =
commentRepliesService.getCommentReplies(commentId, pageToken)
}

View File

@ -0,0 +1,11 @@
package aculix.channelify.app.repository
import aculix.channelify.app.api.CommentService
import aculix.channelify.app.model.Comment
import retrofit2.Response
class CommentsRepository(private val commentService: CommentService) {
suspend fun getVideoComments(videoId: String, pageToken: String?, sortOrder: String): Response<Comment> =
commentService.getVideoComments(videoId, pageToken, sortOrder)
}

View File

@ -0,0 +1,26 @@
package aculix.channelify.app.repository
import aculix.channelify.app.db.FavoriteVideoDao
import aculix.channelify.app.model.FavoriteVideo
import androidx.lifecycle.LiveData
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class FavoritesRepository(private val favoriteVideoDao: FavoriteVideoDao) {
suspend fun getFavoriteVideosFromDb(): List<FavoriteVideo> = withContext(Dispatchers.IO) {
favoriteVideoDao.getAllFavoriteVideos()
}
suspend fun removeVideoFromFavorites(favoriteVideo: FavoriteVideo) {
withContext(Dispatchers.IO) {
favoriteVideoDao.removeFavoriteVideo(favoriteVideo)
}
}
suspend fun addVideoToFavorites(favoriteVideo: FavoriteVideo) {
withContext(Dispatchers.IO) {
favoriteVideoDao.addFavoriteVideo(favoriteVideo)
}
}
}

View File

@ -0,0 +1,20 @@
package aculix.channelify.app.repository
import aculix.channelify.app.api.ChannelsService
import aculix.channelify.app.api.PlaylistItemsService
import aculix.channelify.app.api.SearchVideoService
import aculix.channelify.app.model.ChannelUploadsPlaylistInfo
import aculix.channelify.app.model.SearchedVideo
import retrofit2.Response
class HomeRepository(private val searchVideoService: SearchVideoService,
private val channelsService: ChannelsService,
private val playlistItemsService: PlaylistItemsService) {
suspend fun getUploadsPlaylistId(channelId: String): Response<ChannelUploadsPlaylistInfo> =
channelsService.getChannelUploadsPlaylistInfo(channelId)
suspend fun getLatestVideos(playlistId: String, pageToken: String?) =
playlistItemsService.getPlaylistVideos(playlistId, pageToken)
}

View File

@ -0,0 +1,9 @@
package aculix.channelify.app.repository
import aculix.channelify.app.api.PlaylistItemsService
class PlaylistVideosRepository(private val playlistItemsService: PlaylistItemsService) {
suspend fun getPlaylistVideos(playlistId: String, pageToken: String?) =
playlistItemsService.getPlaylistVideos(playlistId, pageToken)
}

View File

@ -0,0 +1,9 @@
package aculix.channelify.app.repository
import aculix.channelify.app.api.PlaylistsService
class PlaylistsRepository(private val playlistsService: PlaylistsService) {
suspend fun getPlaylists(channelId: String, pageToken: String?) =
playlistsService.getPlaylists(channelId, pageToken)
}

View File

@ -0,0 +1,21 @@
package aculix.channelify.app.repository
import aculix.channelify.app.api.SearchVideoService
import aculix.channelify.app.model.SearchedVideo
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class SearchRepository(private val searchVideoService: SearchVideoService) {
suspend fun searchVideos(searchQuery: String, channelId: String, pageToken: String?) = withContext(Dispatchers.IO) {
val defaultQueryMap = HashMap<String, String>()
defaultQueryMap.apply {
put("part", "id,snippet")
put("fields", "nextPageToken, items(id(videoId), snippet(publishedAt, thumbnails, title))")
put("order", "relevance")
put("type", "video")
}
searchVideoService.searchVideos(searchQuery, channelId, pageToken, defaultQueryMap)
}
}

View File

@ -0,0 +1,35 @@
package aculix.channelify.app.repository
import aculix.channelify.app.api.VideosService
import aculix.channelify.app.db.FavoriteVideoDao
import aculix.channelify.app.model.FavoriteVideo
import aculix.channelify.app.model.Video
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import retrofit2.Response
class VideoDetailsRepository(
private val videosService: VideosService,
private val favoriteVideoDao: FavoriteVideoDao
) {
suspend fun getVideoInfo(videoId: String): Response<Video> =
videosService.getVideoInfo(videoId)
suspend fun addVideoToFavorites(favoriteVideo: FavoriteVideo) {
withContext(Dispatchers.IO) {
favoriteVideoDao.addFavoriteVideo(favoriteVideo)
}
}
suspend fun removeVideoFromFavorites(favoriteVideo: FavoriteVideo) {
withContext(Dispatchers.IO) {
favoriteVideoDao.removeFavoriteVideo(favoriteVideo)
}
}
suspend fun isVideoAddedToFavorites(videoId: String): Boolean = withContext(Dispatchers.IO) {
favoriteVideoDao.getFavoriteVideoId(videoId) != null
}
}

View File

@ -0,0 +1,8 @@
package aculix.channelify.app.repository
import aculix.channelify.app.api.VideosService
import aculix.channelify.app.model.Video
import retrofit2.Response
class VideoPlayerRepository() {
}

View File

@ -0,0 +1,7 @@
package aculix.channelify.app.sharedpref
import com.chibatching.kotpref.KotprefModel
object AppPref : KotprefModel() {
var isLightThemeEnabled by booleanPref(true)
}

View File

@ -0,0 +1,25 @@
package aculix.channelify.app.utils
import android.util.DisplayMetrics
import android.widget.FrameLayout
import androidx.appcompat.app.AppCompatActivity
import com.google.android.gms.ads.AdSize
/**
* Returns the size of the Adaptive Banner Ad based on the screen width
*/
fun AppCompatActivity.getAdaptiveBannerAdSize(adViewContainer: FrameLayout): AdSize {
val display = windowManager.defaultDisplay
val outMetrics = DisplayMetrics()
display.getMetrics(outMetrics)
val density = outMetrics.density
var adWidthPixels = adViewContainer.width.toFloat()
if (adWidthPixels == 0f) {
adWidthPixels = outMetrics.widthPixels.toFloat()
}
val adWidth = (adWidthPixels / density).toInt()
return AdSize.getCurrentOrientationAnchoredAdaptiveBannerAdSize(this, adWidth)
}

View File

@ -0,0 +1,8 @@
package aculix.channelify.app.utils
object Constants {
const val INITIAL_PAGE_LOAD_SIZE = 10
const val PAGE_SIZE = 10
const val YT_API_MAX_RESULTS = 10
}

View File

@ -0,0 +1,35 @@
package aculix.channelify.app.utils
import android.annotation.SuppressLint
import com.github.marlonlom.utilities.timeago.TimeAgo
import java.text.SimpleDateFormat
import java.util.*
object DateTimeUtils {
/**
* Returns time in ago format
* Eg. 14 hours ago
* Eg. 2 days ago
*/
fun getTimeAgo(timeInIso8601: String): String {
val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss")
sdf.timeZone = TimeZone.getTimeZone("GMT")
val timeInMillis = sdf.parse(timeInIso8601).time
return TimeAgo.using(timeInMillis)
}
/**
* Returns data in format MMM dd, yyyy
* Eg. Dec 02, 2019
*/
@SuppressLint("SimpleDateFormat")
fun getPublishedDate(timeInIso8601: String): String {
val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss")
sdf.timeZone = TimeZone.getTimeZone("GMT")
val date = sdf.parse(timeInIso8601)
val publishedDateSdf = SimpleDateFormat("MMM dd, yyyy ")
return publishedDateSdf.format(date)
}
}

View File

@ -0,0 +1,26 @@
package aculix.channelify.app.utils
import android.graphics.Canvas
import android.graphics.drawable.Drawable
import androidx.recyclerview.widget.RecyclerView
class DividerItemDecorator(private val mDivider: Drawable) : RecyclerView.ItemDecoration() {
override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
val dividerLeft = parent.paddingLeft
val dividerRight = parent.width - parent.paddingRight
val childCount = parent.childCount
for (i in 0..childCount - 2) {
val child = parent.getChildAt(i)
val params =
child.layoutParams as RecyclerView.LayoutParams
val dividerTop = child.bottom + params.bottomMargin
val dividerBottom = dividerTop + mDivider.intrinsicHeight
mDivider.setBounds(dividerLeft, dividerTop, dividerRight, dividerBottom)
mDivider.draw(canvas)
}
}
}

View File

@ -0,0 +1,26 @@
package aculix.channelify.app.utils
import android.util.DisplayMetrics
import android.widget.FrameLayout
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.FragmentActivity
import com.google.android.gms.ads.AdSize
/**
* Returns the size of the Adaptive Banner Ad based on the screen width
*/
fun FragmentActivity.getAdaptiveBannerAdSize(adViewContainer: FrameLayout): AdSize {
val display = windowManager.defaultDisplay
val outMetrics = DisplayMetrics()
display.getMetrics(outMetrics)
val density = outMetrics.density
var adWidthPixels = adViewContainer.width.toFloat()
if (adWidthPixels == 0f) {
adWidthPixels = outMetrics.widthPixels.toFloat()
}
val adWidth = (adWidthPixels / density).toInt()
return AdSize.getCurrentOrientationAnchoredAdaptiveBannerAdSize(this, adWidth)
}

View File

@ -0,0 +1,60 @@
package aculix.channelify.app.utils
import android.view.View.SYSTEM_UI_FLAG_LAYOUT_STABLE
import android.view.View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
import android.view.View.SYSTEM_UI_FLAG_FULLSCREEN
import android.view.View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
import android.view.View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
import android.view.View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
import android.app.Activity
import android.view.View
/**
* Class responsible for changing the view from full screen to non-full screen and vice versa.
*/
class FullScreenHelper(private val context: Activity, vararg views: View) {
var views: Array<View> = arrayOf(*views)
/**
* call this method to enter full screen
*/
fun enterFullScreen() {
val decorView = context.window.decorView
hideSystemUi(decorView)
for (view in views) {
view.visibility = View.GONE
view.invalidate()
}
}
/**
* call this method to exit full screen
*/
fun exitFullScreen() {
val decorView = context.window.decorView
showSystemUi(decorView)
for (view in views) {
view.visibility = View.VISIBLE
view.invalidate()
}
}
private fun hideSystemUi(mDecorView: View) {
mDecorView.systemUiVisibility = (SYSTEM_UI_FLAG_LAYOUT_STABLE
or SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
or SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
or SYSTEM_UI_FLAG_HIDE_NAVIGATION
or SYSTEM_UI_FLAG_FULLSCREEN
or SYSTEM_UI_FLAG_IMMERSIVE_STICKY)
}
private fun showSystemUi(mDecorView: View) {
mDecorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE
}
}

View File

@ -0,0 +1,38 @@
package aculix.channelify.app.viewmodel
import aculix.channelify.app.R
import aculix.channelify.app.repository.AboutRepository
import aculix.core.helper.ResultWrapper
import android.content.Context
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch
class AboutViewModel(
private val aboutRepository: AboutRepository,
private val channelId: String,
private val context: Context
) : ViewModel() {
private val _channelInfoLiveData = MutableLiveData<ResultWrapper>()
val channelInfoLiveData: LiveData<ResultWrapper>
get() = _channelInfoLiveData
fun getChannelInfo() {
viewModelScope.launch {
_channelInfoLiveData.value = ResultWrapper.Loading
val response = aboutRepository.getChannelInfo(channelId)
if (response.isSuccessful) {
_channelInfoLiveData.value = ResultWrapper.Success(response.body())
} else {
_channelInfoLiveData.value =
ResultWrapper.Error(context.getString(R.string.error_channel_info))
}
}
}
}

View File

@ -0,0 +1,53 @@
package aculix.channelify.app.viewmodel
import aculix.channelify.app.model.CommentReply
import aculix.channelify.app.paging.NetworkState
import aculix.channelify.app.paging.datasourcefactory.CommentRepliesDataSourceFactory
import aculix.channelify.app.repository.CommentRepliesRepository
import aculix.channelify.app.utils.Constants
import androidx.lifecycle.LiveData
import androidx.lifecycle.Transformations
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.LivePagedListBuilder
import androidx.paging.PagedList
import kotlinx.coroutines.launch
class CommentRepliesViewModel(private val commentRepliesRepository: CommentRepliesRepository) :
ViewModel() {
lateinit var commentRepliesDataSourceFactory: CommentRepliesDataSourceFactory
var commentRepliesLiveData: LiveData<PagedList<CommentReply.Item>>? = null
var networkStateLiveData: LiveData<NetworkState>? = null
fun getCommentReplies(commentId: String) {
if (commentRepliesLiveData == null) {
viewModelScope.launch {
commentRepliesDataSourceFactory =
CommentRepliesDataSourceFactory(
commentRepliesRepository,
viewModelScope,
commentId
)
commentRepliesLiveData =
LivePagedListBuilder(commentRepliesDataSourceFactory, pagedListConfig()).build()
networkStateLiveData =
Transformations.switchMap(commentRepliesDataSourceFactory.commentRepliesDataSourceLiveData) { it.getNetworkState() }
}
}
}
/**
* Retry possible last paged request (ie: network issue)
*/
fun refreshFailedRequest() =
commentRepliesDataSourceFactory.getSource()?.retryFailedQuery()
private fun pagedListConfig() = PagedList.Config.Builder()
.setInitialLoadSizeHint(Constants.INITIAL_PAGE_LOAD_SIZE)
.setEnablePlaceholders(false)
.setPageSize(Constants.PAGE_SIZE)
.build()
}

View File

@ -0,0 +1,81 @@
package aculix.channelify.app.viewmodel
import aculix.channelify.app.model.Comment
import aculix.channelify.app.model.SearchedVideo
import aculix.channelify.app.paging.NetworkState
import aculix.channelify.app.paging.datasourcefactory.CommentsDataSourceFactory
import aculix.channelify.app.repository.CommentsRepository
import aculix.channelify.app.utils.Constants
import android.content.Context
import androidx.lifecycle.*
import androidx.paging.LivePagedListBuilder
import androidx.paging.PagedList
import kotlinx.coroutines.launch
import timber.log.Timber
class CommentsViewModel(
private val commentsRepository: CommentsRepository,
private val context: Context
) : ViewModel() {
lateinit var commentsDataSourceFactory: CommentsDataSourceFactory
var commentsLiveData: LiveData<PagedList<Comment.Item>>? = null
var networkStateLiveData: LiveData<NetworkState>? = null
private var _emptyStateLiveData = MutableLiveData<Boolean>()
val emptyStateLiveData: LiveData<Boolean>
get() = _emptyStateLiveData
fun getVideoComments(videoId: String, sortOrder: String) {
if (commentsLiveData == null) {
viewModelScope.launch {
commentsDataSourceFactory =
CommentsDataSourceFactory(
commentsRepository,
viewModelScope,
videoId,
sortOrder
)
commentsLiveData = LivePagedListBuilder(commentsDataSourceFactory, pagedListConfig())
.setBoundaryCallback(object :
PagedList.BoundaryCallback<Comment.Item>() {
override fun onZeroItemsLoaded() {
super.onZeroItemsLoaded()
_emptyStateLiveData.value = true
}
override fun onItemAtFrontLoaded(itemAtFront: Comment.Item) {
super.onItemAtFrontLoaded(itemAtFront)
_emptyStateLiveData.value = false
}
override fun onItemAtEndLoaded(itemAtEnd: Comment.Item) {
super.onItemAtEndLoaded(itemAtEnd)
_emptyStateLiveData.value = false
}
})
.build()
networkStateLiveData = Transformations.switchMap(commentsDataSourceFactory.commentsDataSourceLiveData) { it.getNetworkState() }
}
}
}
fun sortComments(updatedSortOrder: String) {
commentsDataSourceFactory.setSortOrder(updatedSortOrder)
commentsDataSourceFactory.commentsDataSourceLiveData.value?.invalidate()
}
/**
* Retry possible last paged request (ie: network issue)
*/
fun refreshFailedRequest() =
commentsDataSourceFactory.getSource()?.retryFailedQuery()
private fun pagedListConfig() = PagedList.Config.Builder()
.setInitialLoadSizeHint(Constants.INITIAL_PAGE_LOAD_SIZE)
.setEnablePlaceholders(false)
.setPageSize(Constants.PAGE_SIZE)
.build()
}

View File

@ -0,0 +1,29 @@
package aculix.channelify.app.viewmodel
import aculix.channelify.app.model.FavoriteVideo
import aculix.channelify.app.repository.FavoritesRepository
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.liveData
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class FavoritesViewModel(private val favoritesRepository: FavoritesRepository) : ViewModel() {
var favoriteVideosLiveData: LiveData<List<FavoriteVideo>> = liveData(Dispatchers.IO) {
emit(favoritesRepository.getFavoriteVideosFromDb())
}
fun removeVideoFromFavorites(favoriteVideo: FavoriteVideo) {
viewModelScope.launch {
favoritesRepository.removeVideoFromFavorites(favoriteVideo)
}
}
fun addVideoToFavorites(favoriteVideo: FavoriteVideo) {
viewModelScope.launch {
favoritesRepository.addVideoToFavorites(favoriteVideo)
}
}
}

View File

@ -0,0 +1,73 @@
package aculix.channelify.app.viewmodel
import aculix.channelify.app.R
import aculix.channelify.app.model.PlaylistItemInfo
import aculix.channelify.app.paging.datasourcefactory.HomeDataSourceFactory
import aculix.channelify.app.paging.NetworkState
import aculix.channelify.app.repository.HomeRepository
import aculix.channelify.app.utils.Constants
import aculix.core.helper.ResultWrapper
import android.content.Context
import androidx.lifecycle.*
import androidx.paging.LivePagedListBuilder
import androidx.paging.PagedList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import timber.log.Timber
class HomeViewModel(
private val homeRepository: HomeRepository,
private val channelId: String,
private val context: Context
) : ViewModel() {
private val _uploadsPlaylistIdLiveData = MutableLiveData<ResultWrapper>()
val uploadsPlaylistIdLiveData: LiveData<ResultWrapper>
get() = _uploadsPlaylistIdLiveData
lateinit var homeDataSourceFactory: HomeDataSourceFactory
var latestVideoLiveData: LiveData<PagedList<PlaylistItemInfo.Item>>? = null
var networkStateLiveData: LiveData<NetworkState>? = null
fun getLatestVideos() {
if (latestVideoLiveData == null) {
viewModelScope.launch {
_uploadsPlaylistIdLiveData.value = ResultWrapper.Loading
val uploadsPlaylistIdRequest = async(Dispatchers.IO) { homeRepository.getUploadsPlaylistId(channelId) }
val response = uploadsPlaylistIdRequest.await()
if (response.isSuccessful) {
val playlistId = response.body()!!.items[0].contentDetails.relatedPlaylists.uploads
homeDataSourceFactory =
HomeDataSourceFactory(
homeRepository,
viewModelScope,
playlistId
)
latestVideoLiveData = LivePagedListBuilder(homeDataSourceFactory, pagedListConfig()).build()
networkStateLiveData = Transformations.switchMap(homeDataSourceFactory.homeDataSourceLiveData) { it.getNetworkState() }
_uploadsPlaylistIdLiveData.value = ResultWrapper.Success("")
} else {
Timber.e("Error: ${response.raw()}")
_uploadsPlaylistIdLiveData.value = ResultWrapper.Error(context.getString(R.string.error_fetch_videos))
}
}
}
}
/**
* Retry possible last paged request (ie: network issue)
*/
fun refreshFailedRequest() =
homeDataSourceFactory.getSource()?.retryFailedQuery()
private fun pagedListConfig() = PagedList.Config.Builder()
.setInitialLoadSizeHint(Constants.INITIAL_PAGE_LOAD_SIZE)
.setEnablePlaceholders(false)
.setPageSize(Constants.PAGE_SIZE)
.build()
}

View File

@ -0,0 +1,55 @@
package aculix.channelify.app.viewmodel
import aculix.channelify.app.model.PlaylistItemInfo
import aculix.channelify.app.paging.NetworkState
import aculix.channelify.app.paging.datasourcefactory.PlaylistVideosDataSourceFactory
import aculix.channelify.app.repository.PlaylistVideosRepository
import aculix.channelify.app.utils.Constants
import android.content.Context
import androidx.lifecycle.LiveData
import androidx.lifecycle.Transformations
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.LivePagedListBuilder
import androidx.paging.PagedList
import kotlinx.coroutines.launch
class PlaylistVideosViewModel(
private val playlistVideosRepository: PlaylistVideosRepository
) : ViewModel() {
lateinit var playlistVideosDataSourceFactory: PlaylistVideosDataSourceFactory
var playlistVideosLiveData: LiveData<PagedList<PlaylistItemInfo.Item>>? = null
var networkStateLiveData: LiveData<NetworkState>? = null
fun getPlaylistVideos(playlistId: String) {
if (playlistVideosLiveData == null) {
viewModelScope.launch {
playlistVideosDataSourceFactory =
PlaylistVideosDataSourceFactory(
playlistVideosRepository,
viewModelScope,
playlistId
)
playlistVideosLiveData =
LivePagedListBuilder(playlistVideosDataSourceFactory, pagedListConfig()).build()
networkStateLiveData =
Transformations.switchMap(playlistVideosDataSourceFactory.playlistVideosDataSourceLiveData) { it.getNetworkState() }
}
}
}
/**
* Retry possible last paged request (ie: network issue)
*/
fun refreshFailedRequest() =
playlistVideosDataSourceFactory.getSource()?.retryFailedQuery()
private fun pagedListConfig() = PagedList.Config.Builder()
.setInitialLoadSizeHint(Constants.INITIAL_PAGE_LOAD_SIZE)
.setEnablePlaceholders(false)
.setPageSize(Constants.PAGE_SIZE)
.build()
}

Some files were not shown because too many files have changed in this diff Show More