diff --git a/README.md b/README.md index 1cd6ad0f..20be6c5b 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,54 @@ +banner + +# DroidKnights2023 App with media3 + +2023년 9월 12일 드로이드나이츠에서 발표한 에서 소개한 데모 앱을 공개합니다. + +## 발표 자료 +https://speakerdeck.com/workspace93/jetpack-media3ro-joheun-kontenceu-sobi-gyeongheom-guhyeonhagi + +## Guide + +### Emulator 만들기 + +각 Configuration에 맞는 Emulator를 Android Studio Device Manager에서 생성. + +image + +### Desktop Head Unit Emulator 만들기 (Android Auto) + +공식 [가이드](https://developer.android.com/training/cars/testing/dhu)를 따라 `Desktop Head Unit Emulator(DHU)`를 설치. 모바일 에뮬레이터 또는 실기기가 연결된 상태에서 DHU 실행하면 Android Auto 활성화 + +image + +### Run Configurations + +실행 해보고 싶은 것과 Emulator를 고른 뒤 `Run` + +image + +- app (통상적인 모바일 앱, Android Auto) +- app-wear-os (워치 앱) +- app-tv (Android TV 앱) +- app-automotive (Android Automotive 앱) + +## Resources +### Youtube +- [Google I/O 2014 - Building great multi-media experiences on Android (18:29 ~)](https://www.youtube.com/watch?v=92fgcUNCHic&t=1108s) +- [Android Dev Summit 2021 - What’s next for AndroidX Media and ExoPlayer](https://www.youtube.com/watch?v=sTIBDcyCmCg) +### Android Developers - Media3 +- [Introducing Jetpack Media3](https://android-developers.googleblog.com/2021/10/jetpack-media3.html) +- [Media3 is ready to play!](https://android-developers.googleblog.com/2023/03/media3-is-ready-to-play.html) +- [Introduction to Jetpack Media3](https://developer.android.com/guide/topics/media/media3) +### Android Developers - Wear OS, TV, Auto +- [Using Jetpack Compose on Wear OS](https://developer.android.com/training/wearables/compose) +- [Use Jetpack Compose on Android TV](https://developer.android.com/training/tv/playback/compose) +- [Build media apps for cars](https://developer.android.com/training/cars/media) +### Github +- [Media3 Github](https://github.com/androidx/media) + +
+ DroidKnights2023 App ReadMe 원문 banner # DroidKnights2023 App @@ -101,3 +152,5 @@ - GitHub : [Contributors](https://github.com/droidknights/DroidKnights2023_App/graphs/contributors) - Designer : Eunbi Ko - Maintainer : [laco-dev](https://github.com/laco-dev), [wisemuji](https://github.com/wisemuji) + +
diff --git a/app-automotive/.gitignore b/app-automotive/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/app-automotive/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app-automotive/build.gradle.kts b/app-automotive/build.gradle.kts new file mode 100644 index 00000000..ff95aa11 --- /dev/null +++ b/app-automotive/build.gradle.kts @@ -0,0 +1,29 @@ +plugins { + id("droidknights.android.application") +} + +android { + namespace = "com.droidknights.app2023.automotive" + + defaultConfig { + applicationId = "com.droidknights.app2023.automotive" + versionCode = 1 + versionName = "1.0" + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } + buildTypes { + getByName("release") { + signingConfig = signingConfigs.getByName("debug") + } + } +} + +dependencies { + implementation(projects.core.playback) + implementation(projects.core.designsystem) +} diff --git a/app-automotive/proguard-rules.pro b/app-automotive/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/app-automotive/proguard-rules.pro @@ -0,0 +1,21 @@ +# 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 \ No newline at end of file diff --git a/app-automotive/src/main/AndroidManifest.xml b/app-automotive/src/main/AndroidManifest.xml new file mode 100644 index 00000000..7dfb5df3 --- /dev/null +++ b/app-automotive/src/main/AndroidManifest.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app-automotive/src/main/kotlin/com/droidknights/app2023/automotive/DroidKnightsApplication.kt b/app-automotive/src/main/kotlin/com/droidknights/app2023/automotive/DroidKnightsApplication.kt new file mode 100644 index 00000000..c1a588b5 --- /dev/null +++ b/app-automotive/src/main/kotlin/com/droidknights/app2023/automotive/DroidKnightsApplication.kt @@ -0,0 +1,7 @@ +package com.droidknights.app2023.automotive + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class DroidKnightsApplication : Application() diff --git a/app-automotive/src/main/kotlin/com/droidknights/app2023/automotive/di/AndroidModule.kt b/app-automotive/src/main/kotlin/com/droidknights/app2023/automotive/di/AndroidModule.kt new file mode 100644 index 00000000..28392280 --- /dev/null +++ b/app-automotive/src/main/kotlin/com/droidknights/app2023/automotive/di/AndroidModule.kt @@ -0,0 +1,23 @@ +package com.droidknights.app2023.automotive.di + +import android.app.PendingIntent +import android.content.Context +import com.droidknights.app2023.core.playback.session.SessionActivityIntentProvider +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +internal object AndroidModule { + @Provides + fun provideContext(@ApplicationContext context: Context): Context = context + + @Provides + fun toPlayerIntentProvider(): SessionActivityIntentProvider = + object : SessionActivityIntentProvider { + override fun toPlayer(): PendingIntent? = null + } +} \ No newline at end of file diff --git a/app-automotive/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app-automotive/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..036d09bc --- /dev/null +++ b/app-automotive/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app-automotive/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app-automotive/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 00000000..036d09bc --- /dev/null +++ b/app-automotive/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app-automotive/src/main/res/mipmap-hdpi/ic_launcher.webp b/app-automotive/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 00000000..9690d1ed Binary files /dev/null and b/app-automotive/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app-automotive/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/app-automotive/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..d8f4354d Binary files /dev/null and b/app-automotive/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/app-automotive/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app-automotive/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 00000000..f7e3d57e Binary files /dev/null and b/app-automotive/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app-automotive/src/main/res/mipmap-mdpi/ic_launcher.webp b/app-automotive/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 00000000..a9b2e2b5 Binary files /dev/null and b/app-automotive/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app-automotive/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/app-automotive/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..6be0df8d Binary files /dev/null and b/app-automotive/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/app-automotive/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app-automotive/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 00000000..839dd893 Binary files /dev/null and b/app-automotive/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app-automotive/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app-automotive/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 00000000..97c5b37d Binary files /dev/null and b/app-automotive/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app-automotive/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/app-automotive/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..2bb7e20b Binary files /dev/null and b/app-automotive/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/app-automotive/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app-automotive/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..a2f920b2 Binary files /dev/null and b/app-automotive/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app-automotive/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app-automotive/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 00000000..6743018d Binary files /dev/null and b/app-automotive/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app-automotive/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/app-automotive/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..7cb596d4 Binary files /dev/null and b/app-automotive/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/app-automotive/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app-automotive/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..3cee9a88 Binary files /dev/null and b/app-automotive/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app-automotive/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app-automotive/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 00000000..39f62427 Binary files /dev/null and b/app-automotive/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app-automotive/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/app-automotive/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..588dd63e Binary files /dev/null and b/app-automotive/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/app-automotive/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app-automotive/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..1dad85bf Binary files /dev/null and b/app-automotive/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app-automotive/src/main/res/values/colors.xml b/app-automotive/src/main/res/values/colors.xml new file mode 100644 index 00000000..f8c6127d --- /dev/null +++ b/app-automotive/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app-automotive/src/main/res/values/ic_launcher_background.xml b/app-automotive/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 00000000..cfde9b43 --- /dev/null +++ b/app-automotive/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #141414 + \ No newline at end of file diff --git a/app-automotive/src/main/res/values/strings.xml b/app-automotive/src/main/res/values/strings.xml new file mode 100644 index 00000000..d383fdf6 --- /dev/null +++ b/app-automotive/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + 드로이드나이츠 2023 for automotive + \ No newline at end of file diff --git a/app-automotive/src/main/res/xml/automotive_app_desc.xml b/app-automotive/src/main/res/xml/automotive_app_desc.xml new file mode 100644 index 00000000..0f739ff8 --- /dev/null +++ b/app-automotive/src/main/res/xml/automotive_app_desc.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app-tv/.gitignore b/app-tv/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/app-tv/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app-tv/build.gradle.kts b/app-tv/build.gradle.kts new file mode 100644 index 00000000..1d985750 --- /dev/null +++ b/app-tv/build.gradle.kts @@ -0,0 +1,30 @@ +plugins { + id("droidknights.android.application") +} + +android { + namespace = "com.droidknights.app2023.tv" + + defaultConfig { + applicationId = "com.droidknights.app2023.tv" + versionCode = 1 + versionName = "1.0" + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } + buildTypes { + getByName("release") { + signingConfig = signingConfigs.getByName("debug") + } + } +} + +dependencies { + implementation(projects.core.playback) + implementation(projects.feature.player) + implementation(projects.feature.tvMain) +} \ No newline at end of file diff --git a/app-tv/proguard-rules.pro b/app-tv/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/app-tv/proguard-rules.pro @@ -0,0 +1,21 @@ +# 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 \ No newline at end of file diff --git a/app-tv/src/main/AndroidManifest.xml b/app-tv/src/main/AndroidManifest.xml new file mode 100644 index 00000000..06a0565c --- /dev/null +++ b/app-tv/src/main/AndroidManifest.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app-tv/src/main/java/com/droidknights/app2023/tv/DroidKnightsApplication.kt b/app-tv/src/main/java/com/droidknights/app2023/tv/DroidKnightsApplication.kt new file mode 100644 index 00000000..8ac9cf56 --- /dev/null +++ b/app-tv/src/main/java/com/droidknights/app2023/tv/DroidKnightsApplication.kt @@ -0,0 +1,7 @@ +package com.droidknights.app2023.tv + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class DroidKnightsApplication : Application() diff --git a/app-tv/src/main/java/com/droidknights/app2023/tv/di/AndroidModule.kt b/app-tv/src/main/java/com/droidknights/app2023/tv/di/AndroidModule.kt new file mode 100644 index 00000000..1e356f74 --- /dev/null +++ b/app-tv/src/main/java/com/droidknights/app2023/tv/di/AndroidModule.kt @@ -0,0 +1,23 @@ +package com.droidknights.app2023.tv.di + +import android.app.Application +import android.content.Context +import com.droidknights.app2023.core.playback.session.SessionActivityIntentProvider +import com.droidknights.app2023.tv.misc.SessionActivityIntentProviderImpl +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +internal object AndroidModule { + @Provides + fun provideContext(@ApplicationContext context: Context): Context = context + + @Provides + fun toPlayerIntentProvider( + impl: SessionActivityIntentProviderImpl + ): SessionActivityIntentProvider = impl +} \ No newline at end of file diff --git a/app-tv/src/main/java/com/droidknights/app2023/tv/misc/SessionActivityIntentProviderImpl.kt b/app-tv/src/main/java/com/droidknights/app2023/tv/misc/SessionActivityIntentProviderImpl.kt new file mode 100644 index 00000000..004c107d --- /dev/null +++ b/app-tv/src/main/java/com/droidknights/app2023/tv/misc/SessionActivityIntentProviderImpl.kt @@ -0,0 +1,33 @@ +package com.droidknights.app2023.tv.misc + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import androidx.core.app.TaskStackBuilder +import androidx.core.net.toUri +import com.droidknights.app2023.core.playback.session.SessionActivityIntentProvider +import com.droidknights.app2023.feature.player.navigation.PlayerRoute +import com.droidknights.app2023.feature.tvmain.TvMainActivity +import javax.inject.Inject + +class SessionActivityIntentProviderImpl @Inject constructor( + private val context: Context, +) : SessionActivityIntentProvider { + override fun toPlayer(): PendingIntent? { + val deepLinkIntent = Intent( + Intent.ACTION_VIEW, + PlayerRoute.deepLinkUriPattern.toUri(), + context, + TvMainActivity::class.java + ) + + val deepLinkPendingIntent: PendingIntent? = TaskStackBuilder.create(context).run { + addNextIntentWithParentStack(deepLinkIntent) + getPendingIntent( + 0, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + + } + return deepLinkPendingIntent + } +} diff --git a/app-tv/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app-tv/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..036d09bc --- /dev/null +++ b/app-tv/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app-tv/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app-tv/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 00000000..036d09bc --- /dev/null +++ b/app-tv/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app-tv/src/main/res/mipmap-hdpi/ic_launcher.webp b/app-tv/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 00000000..9690d1ed Binary files /dev/null and b/app-tv/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app-tv/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/app-tv/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..d8f4354d Binary files /dev/null and b/app-tv/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/app-tv/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app-tv/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 00000000..f7e3d57e Binary files /dev/null and b/app-tv/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app-tv/src/main/res/mipmap-mdpi/ic_launcher.webp b/app-tv/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 00000000..a9b2e2b5 Binary files /dev/null and b/app-tv/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app-tv/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/app-tv/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..6be0df8d Binary files /dev/null and b/app-tv/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/app-tv/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app-tv/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 00000000..839dd893 Binary files /dev/null and b/app-tv/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app-tv/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app-tv/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 00000000..97c5b37d Binary files /dev/null and b/app-tv/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app-tv/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/app-tv/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..2bb7e20b Binary files /dev/null and b/app-tv/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/app-tv/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app-tv/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..a2f920b2 Binary files /dev/null and b/app-tv/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app-tv/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app-tv/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 00000000..6743018d Binary files /dev/null and b/app-tv/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app-tv/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/app-tv/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..7cb596d4 Binary files /dev/null and b/app-tv/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/app-tv/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app-tv/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..3cee9a88 Binary files /dev/null and b/app-tv/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app-tv/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app-tv/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 00000000..39f62427 Binary files /dev/null and b/app-tv/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app-tv/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/app-tv/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..588dd63e Binary files /dev/null and b/app-tv/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/app-tv/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app-tv/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..1dad85bf Binary files /dev/null and b/app-tv/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app-tv/src/main/res/values/ic_launcher_background.xml b/app-tv/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 00000000..cfde9b43 --- /dev/null +++ b/app-tv/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #141414 + \ No newline at end of file diff --git a/app-tv/src/main/res/values/strings.xml b/app-tv/src/main/res/values/strings.xml new file mode 100644 index 00000000..e54c648b --- /dev/null +++ b/app-tv/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + 드로이드나이츠 2023 for tv + \ No newline at end of file diff --git a/app-wear-os/.gitignore b/app-wear-os/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/app-wear-os/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app-wear-os/build.gradle.kts b/app-wear-os/build.gradle.kts new file mode 100644 index 00000000..fbc0d3cd --- /dev/null +++ b/app-wear-os/build.gradle.kts @@ -0,0 +1,31 @@ +plugins { + id("droidknights.android.application") +} + +android { + namespace = "com.droidknights.app2023.wear" + + defaultConfig { + applicationId = "com.droidknights.app2023.wear" + versionCode = 1 + versionName = "1.0" + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } + buildTypes { + getByName("release") { + signingConfig = signingConfigs.getByName("debug") + } + } +} + +dependencies { + implementation(projects.core.navigation) + implementation(projects.core.playback) + implementation(projects.feature.wearMain) + implementation(projects.feature.wearPlayer) +} \ No newline at end of file diff --git a/app-wear-os/proguard-rules.pro b/app-wear-os/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/app-wear-os/proguard-rules.pro @@ -0,0 +1,21 @@ +# 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 \ No newline at end of file diff --git a/app-wear-os/src/main/AndroidManifest.xml b/app-wear-os/src/main/AndroidManifest.xml new file mode 100644 index 00000000..2f191b1d --- /dev/null +++ b/app-wear-os/src/main/AndroidManifest.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app-wear-os/src/main/java/com/droidknights/app2023/wear/DroidKnightsApplication.kt b/app-wear-os/src/main/java/com/droidknights/app2023/wear/DroidKnightsApplication.kt new file mode 100644 index 00000000..60b78d6a --- /dev/null +++ b/app-wear-os/src/main/java/com/droidknights/app2023/wear/DroidKnightsApplication.kt @@ -0,0 +1,7 @@ +package com.droidknights.app2023.wear + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class DroidKnightsApplication : Application() diff --git a/app-wear-os/src/main/java/com/droidknights/app2023/wear/di/AndroidModule.kt b/app-wear-os/src/main/java/com/droidknights/app2023/wear/di/AndroidModule.kt new file mode 100644 index 00000000..81c485b8 --- /dev/null +++ b/app-wear-os/src/main/java/com/droidknights/app2023/wear/di/AndroidModule.kt @@ -0,0 +1,23 @@ +package com.droidknights.app2023.wear.di + +import android.app.Application +import android.content.Context +import com.droidknights.app2023.core.playback.session.SessionActivityIntentProvider +import com.droidknights.app2023.wear.misc.SessionActivityIntentProviderImpl +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +internal object AndroidModule { + @Provides + fun provideContext(@ApplicationContext context: Context): Context = context + + @Provides + fun toPlayerIntentProvider( + impl: SessionActivityIntentProviderImpl + ): SessionActivityIntentProvider = impl +} \ No newline at end of file diff --git a/app-wear-os/src/main/java/com/droidknights/app2023/wear/misc/SessionActivityIntentProviderImpl.kt b/app-wear-os/src/main/java/com/droidknights/app2023/wear/misc/SessionActivityIntentProviderImpl.kt new file mode 100644 index 00000000..db727702 --- /dev/null +++ b/app-wear-os/src/main/java/com/droidknights/app2023/wear/misc/SessionActivityIntentProviderImpl.kt @@ -0,0 +1,33 @@ +package com.droidknights.app2023.wear.misc + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import androidx.core.app.TaskStackBuilder +import androidx.core.net.toUri +import com.droidknights.app2023.core.playback.session.SessionActivityIntentProvider +import com.droidknights.app2023.feature.wearmain.WearMainActivity +import com.droidknights.app2023.feature.wearplayer.navigation.WearPlayerRoute +import javax.inject.Inject + +class SessionActivityIntentProviderImpl @Inject constructor( + private val context: Context, +) : SessionActivityIntentProvider { + override fun toPlayer(): PendingIntent? { + val deepLinkIntent = Intent( + Intent.ACTION_VIEW, + WearPlayerRoute.deepLinkUriPattern.toUri(), + context, + WearMainActivity::class.java + ) + + val deepLinkPendingIntent: PendingIntent? = TaskStackBuilder.create(context).run { + addNextIntentWithParentStack(deepLinkIntent) + getPendingIntent( + 0, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + + } + return deepLinkPendingIntent + } +} diff --git a/app-wear-os/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app-wear-os/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..036d09bc --- /dev/null +++ b/app-wear-os/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app-wear-os/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app-wear-os/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 00000000..036d09bc --- /dev/null +++ b/app-wear-os/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app-wear-os/src/main/res/mipmap-hdpi/ic_launcher.webp b/app-wear-os/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 00000000..9690d1ed Binary files /dev/null and b/app-wear-os/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app-wear-os/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/app-wear-os/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..d8f4354d Binary files /dev/null and b/app-wear-os/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/app-wear-os/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app-wear-os/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 00000000..f7e3d57e Binary files /dev/null and b/app-wear-os/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app-wear-os/src/main/res/mipmap-mdpi/ic_launcher.webp b/app-wear-os/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 00000000..a9b2e2b5 Binary files /dev/null and b/app-wear-os/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app-wear-os/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/app-wear-os/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..6be0df8d Binary files /dev/null and b/app-wear-os/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/app-wear-os/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app-wear-os/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 00000000..839dd893 Binary files /dev/null and b/app-wear-os/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app-wear-os/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app-wear-os/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 00000000..97c5b37d Binary files /dev/null and b/app-wear-os/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app-wear-os/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/app-wear-os/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..2bb7e20b Binary files /dev/null and b/app-wear-os/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/app-wear-os/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app-wear-os/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..a2f920b2 Binary files /dev/null and b/app-wear-os/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app-wear-os/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app-wear-os/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 00000000..6743018d Binary files /dev/null and b/app-wear-os/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app-wear-os/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/app-wear-os/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..7cb596d4 Binary files /dev/null and b/app-wear-os/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/app-wear-os/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app-wear-os/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..3cee9a88 Binary files /dev/null and b/app-wear-os/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app-wear-os/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app-wear-os/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 00000000..39f62427 Binary files /dev/null and b/app-wear-os/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app-wear-os/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/app-wear-os/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..588dd63e Binary files /dev/null and b/app-wear-os/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/app-wear-os/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app-wear-os/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..1dad85bf Binary files /dev/null and b/app-wear-os/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app-wear-os/src/main/res/values/ic_launcher_background.xml b/app-wear-os/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 00000000..cfde9b43 --- /dev/null +++ b/app-wear-os/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #141414 + \ No newline at end of file diff --git a/app-wear-os/src/main/res/values/strings.xml b/app-wear-os/src/main/res/values/strings.xml new file mode 100644 index 00000000..e20a8c02 --- /dev/null +++ b/app-wear-os/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + 드로이드나이츠 2023 for wear os + \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a73000b7..d7c72f14 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -26,8 +26,10 @@ android { dependencies { implementation(projects.core.navigation) + implementation(projects.core.playback) implementation(projects.feature.main) implementation(projects.feature.home) + implementation(projects.feature.player) implementation(projects.core.designsystem) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 243ce8bd..6e7d4191 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,9 @@ xmlns:tools="http://schemas.android.com/tools"> + + + + + diff --git a/app/src/main/java/com/droidknights/app2023/di/AndroidModule.kt b/app/src/main/java/com/droidknights/app2023/di/AndroidModule.kt new file mode 100644 index 00000000..5bb85b83 --- /dev/null +++ b/app/src/main/java/com/droidknights/app2023/di/AndroidModule.kt @@ -0,0 +1,23 @@ +package com.droidknights.app2023.di + +import android.app.Application +import android.content.Context +import com.droidknights.app2023.core.playback.session.SessionActivityIntentProvider +import com.droidknights.app2023.misc.SessionActivityIntentProviderImpl +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +internal object AndroidModule { + @Provides + fun provideContext(@ApplicationContext context: Context): Context = context + + @Provides + fun toPlayerIntentProvider( + impl: SessionActivityIntentProviderImpl + ): SessionActivityIntentProvider = impl +} \ No newline at end of file diff --git a/app/src/main/java/com/droidknights/app2023/misc/SessionActivityIntentProviderImpl.kt b/app/src/main/java/com/droidknights/app2023/misc/SessionActivityIntentProviderImpl.kt new file mode 100644 index 00000000..58df60a7 --- /dev/null +++ b/app/src/main/java/com/droidknights/app2023/misc/SessionActivityIntentProviderImpl.kt @@ -0,0 +1,33 @@ +package com.droidknights.app2023.misc + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import androidx.core.app.TaskStackBuilder +import androidx.core.net.toUri +import com.droidknights.app2023.core.playback.session.SessionActivityIntentProvider +import com.droidknights.app2023.feature.main.MainActivity +import com.droidknights.app2023.feature.player.navigation.PlayerRoute +import javax.inject.Inject + +class SessionActivityIntentProviderImpl @Inject constructor( + private val context: Context, +) : SessionActivityIntentProvider { + override fun toPlayer(): PendingIntent? { + val deepLinkIntent = Intent( + Intent.ACTION_VIEW, + PlayerRoute.deepLinkUriPattern.toUri(), + context, + MainActivity::class.java + ) + + val deepLinkPendingIntent: PendingIntent? = TaskStackBuilder.create(context).run { + addNextIntentWithParentStack(deepLinkIntent) + getPendingIntent( + 0, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + + } + return deepLinkPendingIntent + } +} diff --git a/app/src/main/res/xml/automotive_app_desc.xml b/app/src/main/res/xml/automotive_app_desc.xml new file mode 100644 index 00000000..0a6a3c9f --- /dev/null +++ b/app/src/main/res/xml/automotive_app_desc.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/build-logic/src/main/kotlin/com/droidknights/app2023/ComposeAndroid.kt b/build-logic/src/main/kotlin/com/droidknights/app2023/ComposeAndroid.kt index 8ed9c57c..7ed1f06a 100644 --- a/build-logic/src/main/kotlin/com/droidknights/app2023/ComposeAndroid.kt +++ b/build-logic/src/main/kotlin/com/droidknights/app2023/ComposeAndroid.kt @@ -18,7 +18,8 @@ internal fun Project.configureComposeAndroid() { val bom = libs.findLibrary("androidx-compose-bom").get() add("implementation", platform(bom)) add("androidTestImplementation", platform(bom)) - + + add("implementation", libs.findLibrary("androidx.compose.materialIcons").get()) add("implementation", libs.findLibrary("androidx.compose.material3").get()) add("implementation", libs.findLibrary("androidx.compose.ui").get()) add("implementation", libs.findLibrary("androidx.compose.ui.tooling.preview").get()) diff --git a/build-logic/src/main/kotlin/com/droidknights/app2023/Extension.kt b/build-logic/src/main/kotlin/com/droidknights/app2023/Extension.kt index c2883b3f..d4f50025 100644 --- a/build-logic/src/main/kotlin/com/droidknights/app2023/Extension.kt +++ b/build-logic/src/main/kotlin/com/droidknights/app2023/Extension.kt @@ -9,13 +9,13 @@ import org.gradle.api.artifacts.VersionCatalogsExtension import org.gradle.api.plugins.ExtensionContainer import org.gradle.kotlin.dsl.getByType -internal val Project.applicationExtension: CommonExtension<*, *, *, *> +internal val Project.applicationExtension: CommonExtension<*, *, *, *, *> get() = extensions.getByType() -internal val Project.libraryExtension: CommonExtension<*, *, *, *> +internal val Project.libraryExtension: CommonExtension<*, *, *, *, *> get() = extensions.getByType() -internal val Project.androidExtension: CommonExtension<*, *, *, *> +internal val Project.androidExtension: CommonExtension<*, *, *, *, *> get() = runCatching { libraryExtension } .recoverCatching { applicationExtension } .onFailure { println("Could not find Library or Application extension from this project") } diff --git a/build-logic/src/main/kotlin/com/droidknights/app2023/KotlinAndroid.kt b/build-logic/src/main/kotlin/com/droidknights/app2023/KotlinAndroid.kt index 7623d024..d8ee541b 100644 --- a/build-logic/src/main/kotlin/com/droidknights/app2023/KotlinAndroid.kt +++ b/build-logic/src/main/kotlin/com/droidknights/app2023/KotlinAndroid.kt @@ -18,10 +18,10 @@ internal fun Project.configureKotlinAndroid() { // Android settings androidExtension.apply { - compileSdk = 33 + compileSdk = 34 defaultConfig { - minSdk = 24 + minSdk = 26 } compileOptions { diff --git a/core/data/src/main/assets/sessions.json b/core/data/src/main/assets/sessions.json index ac4d4f04..a5aacf22 100644 --- a/core/data/src/main/assets/sessions.json +++ b/core/data/src/main/assets/sessions.json @@ -8,7 +8,11 @@ "tags": [], "room": null, "startTime": "2023-09-12T10:45:00.000", - "endTime": "2023-09-12T11:00:00.000" + "endTime": "2023-09-12T11:00:00.000", + "video": { + "manifestUrl": "https://workspace.github.io/media-samples/sample/output.mpd", + "thumbnailUrl": "https://workspace.github.io/media-samples/sample/thumbnail.jpg" + } }, { "id": "2", @@ -27,7 +31,11 @@ ], "room": "Track1", "startTime": "2023-09-12T11:00:00.000", - "endTime": "2023-09-12T11:30:00.000" + "endTime": "2023-09-12T11:30:00.000", + "video": { + "manifestUrl": "https://workspace.github.io/media-samples/sample/output.mpd", + "thumbnailUrl": "https://workspace.github.io/media-samples/sample/thumbnail.jpg" + } }, { "id": "3", @@ -46,7 +54,11 @@ ], "room": "Track2", "startTime": "2023-09-12T11:00:00.000", - "endTime": "2023-09-12T11:30:00.000" + "endTime": "2023-09-12T11:30:00.000", + "video": { + "manifestUrl": "https://workspace.github.io/media-samples/sample/output.mpd", + "thumbnailUrl": "https://workspace.github.io/media-samples/sample/thumbnail.jpg" + } }, { "id": "4", @@ -65,7 +77,11 @@ ], "room": "Track3", "startTime": "2023-09-12T11:00:00.000", - "endTime": "2023-09-12T11:30:00.000" + "endTime": "2023-09-12T11:30:00.000", + "video": { + "manifestUrl": "https://workspace.github.io/media-samples/sample/output.mpd", + "thumbnailUrl": "https://workspace.github.io/media-samples/sample/thumbnail.jpg" + } }, { "id": "5", @@ -84,7 +100,11 @@ ], "room": "Track1", "startTime": "2023-09-12T11:45:00.000", - "endTime": "2023-09-12T12:15:00.000" + "endTime": "2023-09-12T12:15:00.000", + "video": { + "manifestUrl": "https://workspace.github.io/media-samples/sample/output.mpd", + "thumbnailUrl": "https://workspace.github.io/media-samples/sample/thumbnail.jpg" + } }, { "id": "6", @@ -103,7 +123,11 @@ ], "room": "Track2", "startTime": "2023-09-12T11:45:00.000", - "endTime": "2023-09-12T12:15:00.000" + "endTime": "2023-09-12T12:15:00.000", + "video": { + "manifestUrl": "https://workspace.github.io/media-samples/sample/output.mpd", + "thumbnailUrl": "https://workspace.github.io/media-samples/sample/thumbnail.jpg" + } }, { "id": "7", @@ -122,7 +146,11 @@ ], "room": "Track3", "startTime": "2023-09-12T11:45:00.000", - "endTime": "2023-09-12T12:15:00.000" + "endTime": "2023-09-12T12:15:00.000", + "video": { + "manifestUrl": "https://workspace.github.io/media-samples/sample/output.mpd", + "thumbnailUrl": "https://workspace.github.io/media-samples/sample/thumbnail.jpg" + } }, { "id": "8", @@ -141,7 +169,11 @@ ], "room": "Track1", "startTime": "2023-09-12T13:25:00.000", - "endTime": "2023-09-12T14:10:00.000" + "endTime": "2023-09-12T14:10:00.000", + "video": { + "manifestUrl": "https://workspace.github.io/media-samples/sample/output.mpd", + "thumbnailUrl": "https://workspace.github.io/media-samples/sample/thumbnail.jpg" + } }, { "id": "9", @@ -160,7 +192,11 @@ ], "room": "Track2", "startTime": "2023-09-12T13:25:00.000", - "endTime": "2023-09-12T14:10:00.000" + "endTime": "2023-09-12T14:10:00.000", + "video": { + "manifestUrl": "https://workspace.github.io/media-samples/sample/output.mpd", + "thumbnailUrl": "https://workspace.github.io/media-samples/sample/thumbnail.jpg" + } }, { "id": "10", @@ -179,7 +215,11 @@ ], "room": "Track3", "startTime": "2023-09-12T13:25:00.000", - "endTime": "2023-09-12T14:10:00.000" + "endTime": "2023-09-12T14:10:00.000", + "video": { + "manifestUrl": "https://workspace.github.io/media-samples/sample/output.mpd", + "thumbnailUrl": "https://workspace.github.io/media-samples/sample/thumbnail.jpg" + } }, { "id": "11", @@ -198,7 +238,11 @@ ], "room": "Track1", "startTime": "2023-09-12T14:25:00.000", - "endTime": "2023-09-12T14:55:00.000" + "endTime": "2023-09-12T14:55:00.000", + "video": { + "manifestUrl": "https://workspace.github.io/media-samples/sample/output.mpd", + "thumbnailUrl": "https://workspace.github.io/media-samples/sample/thumbnail.jpg" + } }, { "id": "12", @@ -217,7 +261,11 @@ ], "room": "Track2", "startTime": "2023-09-12T14:25:00.000", - "endTime": "2023-09-12T14:55:00.000" + "endTime": "2023-09-12T14:55:00.000", + "video": { + "manifestUrl": "https://workspace.github.io/media-samples/sample/output.mpd", + "thumbnailUrl": "https://workspace.github.io/media-samples/sample/thumbnail.jpg" + } }, { "id": "13", @@ -236,7 +284,11 @@ ], "room": "Track3", "startTime": "2023-09-12T14:25:00.000", - "endTime": "2023-09-12T14:55:00.000" + "endTime": "2023-09-12T14:55:00.000", + "video": { + "manifestUrl": "https://workspace.github.io/media-samples/sample/output.mpd", + "thumbnailUrl": "https://workspace.github.io/media-samples/sample/thumbnail.jpg" + } }, { "id": "14", @@ -255,7 +307,11 @@ ], "room": "Track1", "startTime": "2023-09-12T15:10:00.000", - "endTime": "2023-09-12T15:40:00.000" + "endTime": "2023-09-12T15:40:00.000", + "video": { + "manifestUrl": "https://workspace.github.io/media-samples/sample/output.mpd", + "thumbnailUrl": "https://workspace.github.io/media-samples/sample/thumbnail.jpg" + } }, { "id": "15", @@ -274,7 +330,11 @@ ], "room": "Track2", "startTime": "2023-09-12T15:10:00.000", - "endTime": "2023-09-12T15:40:00.000" + "endTime": "2023-09-12T15:40:00.000", + "video": { + "manifestUrl": "https://workspace.github.io/media-samples/sample/output.mpd", + "thumbnailUrl": "https://workspace.github.io/media-samples/sample/thumbnail.jpg" + } }, { "id": "16", @@ -293,7 +353,11 @@ ], "room": "Track3", "startTime": "2023-09-12T15:10:00.000", - "endTime": "2023-09-12T15:40:00.000" + "endTime": "2023-09-12T15:40:00.000", + "video": { + "manifestUrl": "https://workspace.github.io/media-samples/sample/output.mpd", + "thumbnailUrl": "https://workspace.github.io/media-samples/sample/thumbnail.jpg" + } }, { "id": "17", @@ -312,7 +376,11 @@ ], "room": "Track1", "startTime": "2023-09-12T16:00:00.000", - "endTime": "2023-09-12T16:45:00.000" + "endTime": "2023-09-12T16:45:00.000", + "video": { + "manifestUrl": "https://workspace.github.io/media-samples/sample/output.mpd", + "thumbnailUrl": "https://workspace.github.io/media-samples/sample/thumbnail.jpg" + } }, { "id": "18", @@ -331,7 +399,11 @@ ], "room": "Track2", "startTime": "2023-09-12T16:00:00.000", - "endTime": "2023-09-12T16:45:00.000" + "endTime": "2023-09-12T16:45:00.000", + "video": { + "manifestUrl": "https://workspace.github.io/media-samples/sample/output.mpd", + "thumbnailUrl": "https://workspace.github.io/media-samples/sample/thumbnail.jpg" + } }, { "id": "19", @@ -350,7 +422,11 @@ ], "room": "Track3", "startTime": "2023-09-12T16:00:00.000", - "endTime": "2023-09-12T16:45:00.000" + "endTime": "2023-09-12T16:45:00.000", + "video": { + "manifestUrl": "https://workspace.github.io/media-samples/sample/output.mpd", + "thumbnailUrl": "https://workspace.github.io/media-samples/sample/thumbnail.jpg" + } }, { "id": "20", @@ -369,7 +445,11 @@ ], "room": "Track1", "startTime": "2023-09-12T17:00:00.000", - "endTime": "2023-09-12T17:30:00.000" + "endTime": "2023-09-12T17:30:00.000", + "video": { + "manifestUrl": "https://workspace.github.io/media-samples/sample/output.mpd", + "thumbnailUrl": "https://workspace.github.io/media-samples/sample/thumbnail.jpg" + } }, { "id": "21", @@ -388,7 +468,11 @@ ], "room": "Track2", "startTime": "2023-09-12T17:00:00.000", - "endTime": "2023-09-12T17:30:00.000" + "endTime": "2023-09-12T17:30:00.000", + "video": { + "manifestUrl": "https://workspace.github.io/media-samples/sample/output.mpd", + "thumbnailUrl": "https://workspace.github.io/media-samples/sample/thumbnail.jpg" + } }, { "id": "22", @@ -407,6 +491,10 @@ ], "room": "Track3", "startTime": "2023-09-12T17:00:00.000", - "endTime": "2023-09-12T17:30:00.000" + "endTime": "2023-09-12T17:30:00.000", + "video": { + "manifestUrl": "https://workspace.github.io/media-samples/sample/output.mpd", + "thumbnailUrl": "https://workspace.github.io/media-samples/sample/thumbnail.jpg" + } } ] diff --git a/core/data/src/main/java/com/droidknights/app2023/core/data/api/GithubRawApi.kt b/core/data/src/main/java/com/droidknights/app2023/core/data/api/GithubRawApi.kt index 7cb079ca..fd15fcd8 100644 --- a/core/data/src/main/java/com/droidknights/app2023/core/data/api/GithubRawApi.kt +++ b/core/data/src/main/java/com/droidknights/app2023/core/data/api/GithubRawApi.kt @@ -6,9 +6,9 @@ import retrofit2.http.GET internal interface GithubRawApi { - @GET("/droidknights/DroidKnights2023_App/main/core/data/src/main/assets/sponsors.json") + @GET("/workspace/DroidKnights2023-app-with-media3/media3-main/core/data/src/main/assets/sponsors.json") suspend fun getSponsors(): List - @GET("/droidknights/DroidKnights2023_App/main/core/data/src/main/assets/sessions.json") + @GET("/workspace/DroidKnights2023-app-with-media3/media3-main/core/data/src/main/assets/sessions.json") suspend fun getSessions(): List } diff --git a/core/data/src/main/java/com/droidknights/app2023/core/data/api/model/SessionResponse.kt b/core/data/src/main/java/com/droidknights/app2023/core/data/api/model/SessionResponse.kt index 4752eb65..91c43120 100644 --- a/core/data/src/main/java/com/droidknights/app2023/core/data/api/model/SessionResponse.kt +++ b/core/data/src/main/java/com/droidknights/app2023/core/data/api/model/SessionResponse.kt @@ -14,4 +14,5 @@ internal data class SessionResponse( val room: RoomResponse?, val startTime: LocalDateTime, val endTime: LocalDateTime, + val video: VideoResponse? = null, ) diff --git a/core/data/src/main/java/com/droidknights/app2023/core/data/api/model/VideoResponse.kt b/core/data/src/main/java/com/droidknights/app2023/core/data/api/model/VideoResponse.kt new file mode 100644 index 00000000..a5b12fc3 --- /dev/null +++ b/core/data/src/main/java/com/droidknights/app2023/core/data/api/model/VideoResponse.kt @@ -0,0 +1,9 @@ +package com.droidknights.app2023.core.data.api.model + +import kotlinx.serialization.Serializable + +@Serializable +internal data class VideoResponse( + val manifestUrl: String, + val thumbnailUrl: String, +) \ No newline at end of file diff --git a/core/data/src/main/java/com/droidknights/app2023/core/data/di/DataModule.kt b/core/data/src/main/java/com/droidknights/app2023/core/data/di/DataModule.kt index 62b742f6..f6e4f4e3 100644 --- a/core/data/src/main/java/com/droidknights/app2023/core/data/di/DataModule.kt +++ b/core/data/src/main/java/com/droidknights/app2023/core/data/di/DataModule.kt @@ -11,7 +11,9 @@ import com.droidknights.app2023.core.data.repository.DefaultSponsorRepository import com.droidknights.app2023.core.data.repository.SessionRepository import com.droidknights.app2023.core.data.repository.SettingsRepository import com.droidknights.app2023.core.data.repository.SponsorRepository +import com.droidknights.app2023.core.datastore.datasource.DefaultPlaybackPreferencesDataSource import com.droidknights.app2023.core.datastore.datasource.DefaultSessionPreferencesDataSource +import com.droidknights.app2023.core.datastore.datasource.PlaybackPreferencesDataSource import com.droidknights.app2023.core.datastore.datasource.SessionPreferencesDataSource import dagger.Binds import dagger.Module @@ -35,6 +37,11 @@ internal abstract class DataModule { repository: DefaultSettingsRepository, ): SettingsRepository + @Binds + abstract fun bindPlaybackLocalDataSource( + dataSource: DefaultPlaybackPreferencesDataSource, + ): PlaybackPreferencesDataSource + @Binds abstract fun bindSessionLocalDataSource( dataSource: DefaultSessionPreferencesDataSource, @@ -54,8 +61,14 @@ internal abstract class DataModule { @Singleton fun provideSessionRepository( githubRawApi: GithubRawApi, + playbackPreferencesDataSource: PlaybackPreferencesDataSource, sessionDataSource: SessionPreferencesDataSource, - ): SessionRepository = DefaultSessionRepository(githubRawApi, sessionDataSource) + ): SessionRepository = + DefaultSessionRepository( + githubRawApi, + playbackPreferencesDataSource, + sessionDataSource + ) @Provides @Singleton diff --git a/core/data/src/main/java/com/droidknights/app2023/core/data/mapper/SessionMapper.kt b/core/data/src/main/java/com/droidknights/app2023/core/data/mapper/SessionMapper.kt index 8340002e..e8bf328d 100644 --- a/core/data/src/main/java/com/droidknights/app2023/core/data/mapper/SessionMapper.kt +++ b/core/data/src/main/java/com/droidknights/app2023/core/data/mapper/SessionMapper.kt @@ -4,11 +4,13 @@ import com.droidknights.app2023.core.data.api.model.LevelResponse import com.droidknights.app2023.core.data.api.model.RoomResponse import com.droidknights.app2023.core.data.api.model.SessionResponse import com.droidknights.app2023.core.data.api.model.SpeakerResponse +import com.droidknights.app2023.core.data.api.model.VideoResponse import com.droidknights.app2023.core.model.Level import com.droidknights.app2023.core.model.Room import com.droidknights.app2023.core.model.Session import com.droidknights.app2023.core.model.Speaker import com.droidknights.app2023.core.model.Tag +import com.droidknights.app2023.core.model.Video internal fun SessionResponse.toData(): Session = Session( id = this.id, @@ -20,7 +22,8 @@ internal fun SessionResponse.toData(): Session = Session( room = this.room?.toData() ?: Room.ETC, startTime = this.startTime, endTime = this.endTime, - isBookmarked = false + video = this.video?.toData(), + isBookmarked = false, ) internal fun LevelResponse.toData(): Level = when (this) { @@ -42,3 +45,16 @@ internal fun SpeakerResponse.toData(): Speaker = Speaker( introduction = this.introduction, imageUrl = this.imageUrl ) + +internal fun VideoResponse.toData(): Video? = + if ( + manifestUrl.isNotBlank() && + thumbnailUrl.isNotBlank() + ) { + Video( + manifestUrl = this.manifestUrl, + thumbnailUrl = this.thumbnailUrl + ) + } else { + null + } diff --git a/core/data/src/main/java/com/droidknights/app2023/core/data/repository/DefaultSessionRepository.kt b/core/data/src/main/java/com/droidknights/app2023/core/data/repository/DefaultSessionRepository.kt index b4163f15..b3254956 100644 --- a/core/data/src/main/java/com/droidknights/app2023/core/data/repository/DefaultSessionRepository.kt +++ b/core/data/src/main/java/com/droidknights/app2023/core/data/repository/DefaultSessionRepository.kt @@ -1,20 +1,24 @@ package com.droidknights.app2023.core.data.repository import com.droidknights.app2023.core.data.api.GithubRawApi -import com.droidknights.app2023.core.datastore.datasource.SessionPreferencesDataSource import com.droidknights.app2023.core.data.mapper.toData +import com.droidknights.app2023.core.datastore.datasource.PlaybackPreferencesDataSource +import com.droidknights.app2023.core.datastore.datasource.SessionPreferencesDataSource import com.droidknights.app2023.core.model.Session import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull import javax.inject.Inject internal class DefaultSessionRepository @Inject constructor( private val githubRawApi: GithubRawApi, - private val sessionDataSource: SessionPreferencesDataSource + private val playbackDataSource: PlaybackPreferencesDataSource, + private val sessionDataSource: SessionPreferencesDataSource, ) : SessionRepository { private var cachedSessions: List = emptyList() + private val currentPlayingSessionId: Flow = playbackDataSource.currentPlayingSessionId private val bookmarkIds: Flow> = sessionDataSource.bookmarkedSession override suspend fun getSessions(): List { @@ -47,4 +51,12 @@ internal class DefaultSessionRepository @Inject constructor( } ) } + + override suspend fun getCurrentPlayingSession(): Session? { + return currentPlayingSessionId.firstOrNull()?.let { getSession(it) } + } + + override suspend fun updateCurrentPlayingSession(sessionId: String) { + playbackDataSource.updateCurrentPlayingSession(sessionId) + } } diff --git a/core/data/src/main/java/com/droidknights/app2023/core/data/repository/SessionRepository.kt b/core/data/src/main/java/com/droidknights/app2023/core/data/repository/SessionRepository.kt index 7e2b7061..b6337bae 100644 --- a/core/data/src/main/java/com/droidknights/app2023/core/data/repository/SessionRepository.kt +++ b/core/data/src/main/java/com/droidknights/app2023/core/data/repository/SessionRepository.kt @@ -12,4 +12,8 @@ interface SessionRepository { suspend fun getBookmarkedSessionIds(): Flow> suspend fun bookmarkSession(sessionId: String, bookmark: Boolean) + + suspend fun getCurrentPlayingSession(): Session? + + suspend fun updateCurrentPlayingSession(sessionId: String) } diff --git a/core/data/src/test/java/com/droidknights/app2023/core/data/datastore/fake/FakePlaybackPreferencesDataSource.kt b/core/data/src/test/java/com/droidknights/app2023/core/data/datastore/fake/FakePlaybackPreferencesDataSource.kt new file mode 100644 index 00000000..501e9f32 --- /dev/null +++ b/core/data/src/test/java/com/droidknights/app2023/core/data/datastore/fake/FakePlaybackPreferencesDataSource.kt @@ -0,0 +1,14 @@ +package com.droidknights.app2023.core.data.datastore.fake + +import com.droidknights.app2023.core.datastore.datasource.PlaybackPreferencesDataSource +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +class FakePlaybackPreferencesDataSource : PlaybackPreferencesDataSource { + private val _currentPlayingSessionId = MutableStateFlow(null) + override val currentPlayingSessionId: Flow = _currentPlayingSessionId + + override suspend fun updateCurrentPlayingSession(sessionId: String) { + _currentPlayingSessionId.value = sessionId + } +} diff --git a/core/data/src/test/java/com/droidknights/app2023/core/data/repository/DefaultSessionRepositoryTest.kt b/core/data/src/test/java/com/droidknights/app2023/core/data/repository/DefaultSessionRepositoryTest.kt index 49b63463..c265b007 100644 --- a/core/data/src/test/java/com/droidknights/app2023/core/data/repository/DefaultSessionRepositoryTest.kt +++ b/core/data/src/test/java/com/droidknights/app2023/core/data/repository/DefaultSessionRepositoryTest.kt @@ -2,10 +2,12 @@ package com.droidknights.app2023.core.data.repository import app.cash.turbine.test import com.droidknights.app2023.core.data.api.fake.FakeGithubRawApi +import com.droidknights.app2023.core.data.datastore.fake.FakePlaybackPreferencesDataSource import com.droidknights.app2023.core.data.datastore.fake.FakeSessionPreferencesDataSource import com.droidknights.app2023.core.model.Level import com.droidknights.app2023.core.model.Room import com.droidknights.app2023.core.model.Session +import com.droidknights.app2023.core.model.Video import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.shouldBe import kotlinx.datetime.LocalDateTime @@ -15,7 +17,8 @@ internal class DefaultSessionRepositoryTest : StringSpec() { init { val repository: SessionRepository = DefaultSessionRepository( githubRawApi = FakeGithubRawApi(), - sessionDataSource = FakeSessionPreferencesDataSource() + sessionDataSource = FakeSessionPreferencesDataSource(), + playbackDataSource = FakePlaybackPreferencesDataSource() ) "역직렬화 테스트" { val expected = Session( @@ -28,7 +31,11 @@ internal class DefaultSessionRepositoryTest : StringSpec() { room = Room.ETC, startTime = LocalDateTime(2023, 9, 12, 10, 45), endTime = LocalDateTime(2023, 9, 12, 11, 0), - isBookmarked = false + video = Video( + manifestUrl = "https://workspace.github.io/media-samples/sample/output.mpd", + thumbnailUrl = "https://workspace.github.io/media-samples/sample/thumbnail.jpg" + ), + isBookmarked = false, ) val actual = repository.getSessions() actual.first() shouldBe expected @@ -65,5 +72,26 @@ internal class DefaultSessionRepositoryTest : StringSpec() { awaitItem() shouldBe setOf("1") } } + + "현재 재생 중인 세션 업데이트 테스트" { + repository.getCurrentPlayingSession() shouldBe null + repository.updateCurrentPlayingSession("1") + repository.getCurrentPlayingSession() shouldBe Session( + id = "1", + title = "Keynote", + content = "", + speakers = emptyList(), + level = Level.ETC, + tags = emptyList(), + room = Room.ETC, + startTime = LocalDateTime(2023, 9, 12, 10, 45), + endTime = LocalDateTime(2023, 9, 12, 11, 0), + video = Video( + manifestUrl = "https://workspace.github.io/media-samples/sample/output.mpd", + thumbnailUrl = "https://workspace.github.io/media-samples/sample/thumbnail.jpg" + ), + isBookmarked = false, + ) + } } } diff --git a/core/datastore/src/main/java/com/droidknights/app2023/core/datastore/datasource/DefaultPlaybackPreferencesDataSource.kt b/core/datastore/src/main/java/com/droidknights/app2023/core/datastore/datasource/DefaultPlaybackPreferencesDataSource.kt new file mode 100644 index 00000000..e817a74d --- /dev/null +++ b/core/datastore/src/main/java/com/droidknights/app2023/core/datastore/datasource/DefaultPlaybackPreferencesDataSource.kt @@ -0,0 +1,28 @@ +package com.droidknights.app2023.core.datastore.datasource + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Named + +class DefaultPlaybackPreferencesDataSource @Inject constructor( + @Named("playback") private val dataStore: DataStore +) : PlaybackPreferencesDataSource { + object PreferencesKey { + val CURRENT_SESSION_ID = stringPreferencesKey("CURRENT_SESSION_ID") + } + + override val currentPlayingSessionId: Flow = dataStore.data.map { preferences -> + preferences[PreferencesKey.CURRENT_SESSION_ID] + } + + override suspend fun updateCurrentPlayingSession(sessionId: String) { + dataStore.edit { preferences -> + preferences[PreferencesKey.CURRENT_SESSION_ID] = sessionId + } + } +} diff --git a/core/datastore/src/main/java/com/droidknights/app2023/core/datastore/datasource/PlaybackPreferencesDataSource.kt b/core/datastore/src/main/java/com/droidknights/app2023/core/datastore/datasource/PlaybackPreferencesDataSource.kt new file mode 100644 index 00000000..141a7494 --- /dev/null +++ b/core/datastore/src/main/java/com/droidknights/app2023/core/datastore/datasource/PlaybackPreferencesDataSource.kt @@ -0,0 +1,8 @@ +package com.droidknights.app2023.core.datastore.datasource + +import kotlinx.coroutines.flow.Flow + +interface PlaybackPreferencesDataSource { + val currentPlayingSessionId: Flow + suspend fun updateCurrentPlayingSession(sessionId: String) +} diff --git a/core/datastore/src/main/java/com/droidknights/app2023/core/datastore/di/DataStoreModule.kt b/core/datastore/src/main/java/com/droidknights/app2023/core/datastore/di/DataStoreModule.kt index eb5319e6..9472c6d5 100644 --- a/core/datastore/src/main/java/com/droidknights/app2023/core/datastore/di/DataStoreModule.kt +++ b/core/datastore/src/main/java/com/droidknights/app2023/core/datastore/di/DataStoreModule.kt @@ -15,11 +15,20 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object DataStoreModule { + private const val PLAYBACK_DATASTORE_NAME = "PLAYBACK_PREFERENCES" private const val SETTING_DATASTORE_NAME = "SETTINGS_PREFERENCES" private const val SESSION_DATASTORE_NAME = "SESSION_PREFERENCES" + private val Context.playbackDataStore by preferencesDataStore(PLAYBACK_DATASTORE_NAME) private val Context.settingDataStore by preferencesDataStore(SETTING_DATASTORE_NAME) private val Context.sessionDataStore by preferencesDataStore(SESSION_DATASTORE_NAME) + @Provides + @Singleton + @Named("playback") + fun providePlaybackDataStore( + @ApplicationContext context: Context, + ): DataStore = context.playbackDataStore + @Provides @Singleton @Named("setting") diff --git a/core/domain/src/main/java/com/droidknights/app2023/core/domain/usecase/GetCurrentPlayingSessionUseCase.kt b/core/domain/src/main/java/com/droidknights/app2023/core/domain/usecase/GetCurrentPlayingSessionUseCase.kt new file mode 100644 index 00000000..fc29b0fe --- /dev/null +++ b/core/domain/src/main/java/com/droidknights/app2023/core/domain/usecase/GetCurrentPlayingSessionUseCase.kt @@ -0,0 +1,13 @@ +package com.droidknights.app2023.core.domain.usecase + +import com.droidknights.app2023.core.data.repository.SessionRepository +import com.droidknights.app2023.core.model.Session +import javax.inject.Inject + +class GetCurrentPlayingSessionUseCase @Inject constructor( + private val sessionRepository: SessionRepository, +) { + suspend operator fun invoke(): Session? { + return sessionRepository.getCurrentPlayingSession() + } +} diff --git a/core/domain/src/main/java/com/droidknights/app2023/core/domain/usecase/UpdateCurrentPlayingSessionUseCase.kt b/core/domain/src/main/java/com/droidknights/app2023/core/domain/usecase/UpdateCurrentPlayingSessionUseCase.kt new file mode 100644 index 00000000..22d21b4d --- /dev/null +++ b/core/domain/src/main/java/com/droidknights/app2023/core/domain/usecase/UpdateCurrentPlayingSessionUseCase.kt @@ -0,0 +1,13 @@ +package com.droidknights.app2023.core.domain.usecase + +import com.droidknights.app2023.core.data.repository.SessionRepository +import javax.inject.Inject + +class UpdateCurrentPlayingSessionUseCase @Inject constructor( + private val sessionRepository: SessionRepository, +) { + + suspend operator fun invoke(sessionId: String) { + return sessionRepository.updateCurrentPlayingSession(sessionId) + } +} diff --git a/core/domain/src/test/java/com/droidknights/app2023/core/domain/usecase/FakeSessionRepository.kt b/core/domain/src/test/java/com/droidknights/app2023/core/domain/usecase/FakeSessionRepository.kt index e6c13acd..e2e27189 100644 --- a/core/domain/src/test/java/com/droidknights/app2023/core/domain/usecase/FakeSessionRepository.kt +++ b/core/domain/src/test/java/com/droidknights/app2023/core/domain/usecase/FakeSessionRepository.kt @@ -25,4 +25,12 @@ internal class FakeSessionRepository( override suspend fun bookmarkSession(sessionId: String, bookmark: Boolean) { return } + + override suspend fun getCurrentPlayingSession(): Session? { + return null + } + + override suspend fun updateCurrentPlayingSession(sessionId: String) { + return + } } diff --git a/core/domain/src/test/java/com/droidknights/app2023/core/domain/usecase/GetBookmarkedSessionsUseCaseTest.kt b/core/domain/src/test/java/com/droidknights/app2023/core/domain/usecase/GetBookmarkedSessionsUseCaseTest.kt index 8c00b3c5..c95b8eaa 100644 --- a/core/domain/src/test/java/com/droidknights/app2023/core/domain/usecase/GetBookmarkedSessionsUseCaseTest.kt +++ b/core/domain/src/test/java/com/droidknights/app2023/core/domain/usecase/GetBookmarkedSessionsUseCaseTest.kt @@ -60,6 +60,7 @@ internal class GetBookmarkedSessionsUseCaseTest : BehaviorSpec() { room = Room.TRACK1, startTime = LocalDateTime(2023, 10, 5, 11, 0), endTime = LocalDateTime(2023, 10, 5, 11, 50), + video = null, isBookmarked = false ), Session( @@ -72,6 +73,7 @@ internal class GetBookmarkedSessionsUseCaseTest : BehaviorSpec() { room = Room.TRACK1, startTime = LocalDateTime(2023, 10, 5, 9, 0), endTime = LocalDateTime(2023, 10, 5, 9, 50), + video = null, isBookmarked = false ), Session( @@ -84,6 +86,7 @@ internal class GetBookmarkedSessionsUseCaseTest : BehaviorSpec() { room = Room.TRACK1, startTime = LocalDateTime(2023, 10, 5, 10, 0), endTime = LocalDateTime(2023, 10, 5, 10, 50), + video = null, isBookmarked = false ) ) diff --git a/core/model/src/main/java/com/droidknights/app2023/core/model/Session.kt b/core/model/src/main/java/com/droidknights/app2023/core/model/Session.kt index 3a035baf..8d87ff80 100644 --- a/core/model/src/main/java/com/droidknights/app2023/core/model/Session.kt +++ b/core/model/src/main/java/com/droidknights/app2023/core/model/Session.kt @@ -12,5 +12,6 @@ data class Session( val room: Room, val startTime: LocalDateTime, val endTime: LocalDateTime, - val isBookmarked: Boolean + val video: Video?, + val isBookmarked: Boolean, ) diff --git a/core/model/src/main/java/com/droidknights/app2023/core/model/Video.kt b/core/model/src/main/java/com/droidknights/app2023/core/model/Video.kt new file mode 100644 index 00000000..9f666643 --- /dev/null +++ b/core/model/src/main/java/com/droidknights/app2023/core/model/Video.kt @@ -0,0 +1,6 @@ +package com.droidknights.app2023.core.model + +data class Video( + val manifestUrl: String, + val thumbnailUrl: String, +) \ No newline at end of file diff --git a/core/playback/.gitignore b/core/playback/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/core/playback/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/playback/build.gradle.kts b/core/playback/build.gradle.kts new file mode 100644 index 00000000..1ae4b04b --- /dev/null +++ b/core/playback/build.gradle.kts @@ -0,0 +1,18 @@ +plugins { + id("droidknights.android.library") + id("kotlinx-serialization") +} + +android { + namespace = "com.droidknights.app2023.core.playback" +} + +dependencies { + implementation(projects.core.model) + implementation(projects.core.data) + implementation(libs.coroutines.guava) + implementation(libs.kotlinx.serialization.json) + implementation(libs.androidx.media3.player) + implementation(libs.androidx.media3.player.session) + implementation(libs.androidx.media3.player.dash) +} diff --git a/core/playback/src/main/AndroidManifest.xml b/core/playback/src/main/AndroidManifest.xml new file mode 100644 index 00000000..f2a171ba --- /dev/null +++ b/core/playback/src/main/AndroidManifest.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/core/playback/src/main/kotlin/com/droidknights/app2023/core/playback/PlayerController.kt b/core/playback/src/main/kotlin/com/droidknights/app2023/core/playback/PlayerController.kt new file mode 100644 index 00000000..f579d543 --- /dev/null +++ b/core/playback/src/main/kotlin/com/droidknights/app2023/core/playback/PlayerController.kt @@ -0,0 +1,122 @@ +package com.droidknights.app2023.core.playback + +import android.content.ComponentName +import android.content.Context +import androidx.media3.common.Player +import androidx.media3.session.MediaController +import androidx.media3.session.SessionToken +import com.droidknights.app2023.core.data.repository.SessionRepository +import com.droidknights.app2023.core.playback.session.MediaId +import com.droidknights.app2023.core.playback.session.MediaItemProvider +import com.droidknights.app2023.core.playback.session.PlaybackService +import com.droidknights.app2023.core.playback.session.toMediaIdOrNull +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.guava.asDeferred +import kotlinx.coroutines.launch +import javax.inject.Inject + +class PlayerController @Inject constructor( + private val context: Context, + private val sessionRepository: SessionRepository, + private val mediaItemProvider: MediaItemProvider, +) { + + private var controllerDeferred: Deferred = newControllerAsync() + + private fun newControllerAsync() = MediaController + .Builder(context, SessionToken(context, ComponentName(context, PlaybackService::class.java))) + .buildAsync() + .asDeferred() + + @OptIn(ExperimentalCoroutinesApi::class) + private val activeControllerDeferred: Deferred + get() { + if (controllerDeferred.isCompleted) { + val completedController = controllerDeferred.getCompleted() + if (!completedController.isConnected) { + completedController.release() + controllerDeferred = newControllerAsync() + } + } + return controllerDeferred + } + private val scope = CoroutineScope(Dispatchers.Main.immediate) + + fun setPosition(positionMs: Long) = executeAfterPrepare { controller -> + controller.seekTo(positionMs) + } + + fun fastForward() = executeAfterPrepare { controller -> + controller.seekForward() + } + + fun rewind() = executeAfterPrepare { controller -> + controller.seekBack() + } + + fun previous() = executeAfterPrepare { controller -> + controller.seekToPrevious() + } + + fun next() = executeAfterPrepare { controller -> + controller.seekToNext() + } + + fun play() = executeAfterPrepare { controller -> + controller.play() + } + + fun playPause() = executeAfterPrepare { controller -> + if (controller.isPlaying) { + controller.pause() + } else { + controller.play() + } + } + + fun setSpeed(speed: Float) = executeAfterPrepare { controller -> + controller.setPlaybackSpeed(speed) + } + + private suspend fun maybePrepare(controller: MediaController): Boolean { + val currentPlayingSessionId = sessionRepository.getCurrentPlayingSession()?.id ?: return false + if (controller.currentSessionId() == currentPlayingSessionId && + controller.playbackState in listOf(Player.STATE_READY, Player.STATE_BUFFERING) + ) { + return true + } + val currentPlayingSession = runCatching { + sessionRepository.getSession(currentPlayingSessionId) + } + .getOrNull() + ?: return false + controller.setMediaItem(mediaItemProvider.mediaItem(currentPlayingSession)) + controller.prepare() + return true + } + + private fun MediaController.currentSessionId(): String? = + (currentMediaItem?.mediaId?.toMediaIdOrNull() as? MediaId.Session)?.id + + private inline fun executeAfterPrepare(crossinline action: suspend (MediaController) -> Unit) { + scope.launch { + val controller = awaitConnect() ?: return@launch + if (maybePrepare(controller)) { + action(controller) + } + } + } + + private suspend fun awaitConnect(): MediaController? { + return runCatching { + activeControllerDeferred.await() + }.getOrElse { e -> + if (e is CancellationException) throw e + null + } + } +} diff --git a/core/playback/src/main/kotlin/com/droidknights/app2023/core/playback/di/PlaybackModule.kt b/core/playback/src/main/kotlin/com/droidknights/app2023/core/playback/di/PlaybackModule.kt new file mode 100644 index 00000000..8a92f0e6 --- /dev/null +++ b/core/playback/src/main/kotlin/com/droidknights/app2023/core/playback/di/PlaybackModule.kt @@ -0,0 +1,83 @@ +package com.droidknights.app2023.core.playback.di + +import android.app.Service +import androidx.media3.common.AudioAttributes +import androidx.media3.common.C +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DefaultDataSource +import androidx.media3.exoplayer.DefaultRenderersFactory +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.dash.DashMediaSource +import androidx.media3.exoplayer.dash.DefaultDashChunkSource +import androidx.media3.exoplayer.upstream.DefaultBandwidthMeter +import androidx.media3.session.MediaLibraryService +import com.droidknights.app2023.core.playback.playstate.PlaybackStateListener +import com.droidknights.app2023.core.playback.session.LibrarySessionCallback +import com.droidknights.app2023.core.playback.session.PlaybackService +import com.droidknights.app2023.core.playback.session.SessionActivityIntentProvider +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ServiceComponent +import dagger.hilt.android.scopes.ServiceScoped +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob + +@UnstableApi @Module +@InstallIn(ServiceComponent::class) +internal object PlaybackModule { + + @Provides + @ServiceScoped + fun player( + service: Service, + playbackStateListener: PlaybackStateListener, + ): Player { + val dataSourceFactory = DefaultDataSource.Factory(service) + val mediaSourceFactory = DashMediaSource.Factory( + DefaultDashChunkSource.Factory( + dataSourceFactory.setTransferListener( + DefaultBandwidthMeter.Builder(service).build() + ) + ), + dataSourceFactory + ) + val audioAttributes = AudioAttributes.Builder() + .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE) + .setUsage(C.USAGE_MEDIA) + .build() + val renderersFactory = DefaultRenderersFactory(service) + .forceEnableMediaCodecAsynchronousQueueing() + return ExoPlayer.Builder(service, renderersFactory, mediaSourceFactory) + .setAudioAttributes(audioAttributes, true) + .setHandleAudioBecomingNoisy(true) + .build() + .also { player -> + playbackStateListener.attachTo(player) + } + } + + @Provides + @ServiceScoped + fun scope(): CoroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + + @Provides + @ServiceScoped + fun session( + service: Service, + player: Player, + callback: LibrarySessionCallback, + sessionActivityIntentProvider: SessionActivityIntentProvider + ): MediaLibraryService.MediaLibrarySession { + return MediaLibraryService.MediaLibrarySession.Builder(service as PlaybackService, player, callback) + .apply { + val pendingIntent = sessionActivityIntentProvider.toPlayer() + if (pendingIntent != null) { + setSessionActivity(pendingIntent) + } + } + .build() + } +} \ No newline at end of file diff --git a/core/playback/src/main/kotlin/com/droidknights/app2023/core/playback/playstate/PlaybackState.kt b/core/playback/src/main/kotlin/com/droidknights/app2023/core/playback/playstate/PlaybackState.kt new file mode 100644 index 00000000..39fe2bb2 --- /dev/null +++ b/core/playback/src/main/kotlin/com/droidknights/app2023/core/playback/playstate/PlaybackState.kt @@ -0,0 +1,17 @@ +package com.droidknights.app2023.core.playback.playstate + +import android.net.Uri +import androidx.media3.common.C + +data class PlaybackState( + val isPlaying: Boolean = false, + val hasPrevious: Boolean = false, + val hasNext: Boolean = false, + val position: Long = C.TIME_UNSET, + val duration: Long = C.TIME_UNSET, + val speed: Float = 1F, + val aspectRatio: Float = 16F / 9F, + val title: String? = null, + val artist: String? = null, + val artworkUri: Uri? = null, +) diff --git a/core/playback/src/main/kotlin/com/droidknights/app2023/core/playback/playstate/PlaybackStateListener.kt b/core/playback/src/main/kotlin/com/droidknights/app2023/core/playback/playstate/PlaybackStateListener.kt new file mode 100644 index 00000000..31dda48c --- /dev/null +++ b/core/playback/src/main/kotlin/com/droidknights/app2023/core/playback/playstate/PlaybackStateListener.kt @@ -0,0 +1,91 @@ +package com.droidknights.app2023.core.playback.playstate + +import androidx.media3.common.PlaybackParameters +import androidx.media3.common.Player +import androidx.media3.common.VideoSize +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds + +internal class PlaybackStateListener @Inject constructor( + private val scope: CoroutineScope, + private val playbackStateManager: PlaybackStateManager +) : Player.Listener { + + private lateinit var player: Player + private var job: Job? = null + + fun attachTo(player: Player) { + this.player = player + player.addListener(this) + + job?.cancel() + job = scope.launch { + playbackStateManager.flow + .map { it.isPlaying } + .collectLatest { isPlaying -> + if (isPlaying) { + while (true) { + updatePlayState() + delay(400.milliseconds) + } + } + } + } + } + + override fun onPlaybackStateChanged(playbackState: Int) { + updatePlayState() + } + + override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { + updatePlayState() + } + + override fun onPositionDiscontinuity( + oldPosition: Player.PositionInfo, + newPosition: Player.PositionInfo, + reason: Int + ) { + updatePlayState() + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + updatePlayState() + } + + override fun onPlaybackParametersChanged(playbackParameters: PlaybackParameters) { + updatePlayState() + } + + override fun onVideoSizeChanged(videoSize: VideoSize) { + updatePlayState() + } + + private fun updatePlayState() { + val playbackState = player.playbackState + playbackStateManager.playbackState = PlaybackState( + isPlaying = when { + playbackState == Player.STATE_ENDED || playbackState == Player.STATE_IDLE -> false + player.playWhenReady -> true + else -> false + }, + hasPrevious = player.currentMediaItemIndex == 0 && player.hasPreviousMediaItem(), + hasNext = player.hasNextMediaItem(), + position = player.contentPosition, + duration = player.duration, + speed = player.playbackParameters.speed, + aspectRatio = with(player.videoSize) { + if (height == 0 || width == 0) 16F / 9F else width * pixelWidthHeightRatio / height + }, + title = player.currentMediaItem?.mediaMetadata?.title?.toString(), + artist = player.currentMediaItem?.mediaMetadata?.artist?.toString(), + artworkUri = player.currentMediaItem?.mediaMetadata?.artworkUri + ) + } +} \ No newline at end of file diff --git a/core/playback/src/main/kotlin/com/droidknights/app2023/core/playback/playstate/PlaybackStateManager.kt b/core/playback/src/main/kotlin/com/droidknights/app2023/core/playback/playstate/PlaybackStateManager.kt new file mode 100644 index 00000000..1f2577f5 --- /dev/null +++ b/core/playback/src/main/kotlin/com/droidknights/app2023/core/playback/playstate/PlaybackStateManager.kt @@ -0,0 +1,18 @@ +package com.droidknights.app2023.core.playback.playstate + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class PlaybackStateManager @Inject constructor() { + private val _playbackState = MutableStateFlow(PlaybackState()) + + val flow: StateFlow get() = _playbackState + var playbackState: PlaybackState + set(value) { + _playbackState.value = value + } + get() = _playbackState.value +} \ No newline at end of file diff --git a/core/playback/src/main/kotlin/com/droidknights/app2023/core/playback/session/LibrarySessionCallback.kt b/core/playback/src/main/kotlin/com/droidknights/app2023/core/playback/session/LibrarySessionCallback.kt new file mode 100644 index 00000000..2fb53d9f --- /dev/null +++ b/core/playback/src/main/kotlin/com/droidknights/app2023/core/playback/session/LibrarySessionCallback.kt @@ -0,0 +1,92 @@ +package com.droidknights.app2023.core.playback.session + +import androidx.media3.common.MediaItem +import androidx.media3.session.LibraryResult +import androidx.media3.session.LibraryResult.RESULT_ERROR_NOT_SUPPORTED +import androidx.media3.session.MediaLibraryService.LibraryParams +import androidx.media3.session.MediaLibraryService.MediaLibrarySession +import androidx.media3.session.MediaSession +import androidx.media3.session.MediaSession.ControllerInfo +import com.google.common.collect.ImmutableList +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.guava.future +import javax.inject.Inject + +internal class LibrarySessionCallback @Inject constructor( + private val mediaItemProvider: MediaItemProvider, + private val scope: CoroutineScope, +) : MediaLibrarySession.Callback { + + override fun onGetLibraryRoot( + session: MediaLibrarySession, + browser: ControllerInfo, + params: LibraryParams?, + ): ListenableFuture> { + if (params?.isRecent == true) { + return Futures.immediateFuture(LibraryResult.ofError(RESULT_ERROR_NOT_SUPPORTED)) + } + return Futures.immediateFuture(LibraryResult.ofItem(mediaItemProvider.root(), params)) + } + + override fun onGetItem( + session: MediaLibrarySession, + browser: ControllerInfo, + mediaId: String, + ): ListenableFuture> = scope.future { + val item = mediaItemProvider.item(mediaId) + if (item != null) { + LibraryResult.ofItem(item, null) + } else { + LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE) + } + } + + override fun onGetChildren( + session: MediaLibrarySession, + browser: ControllerInfo, + parentId: String, + page: Int, + pageSize: Int, + params: LibraryParams?, + ): ListenableFuture>> = scope.future { + val children = mediaItemProvider.children(parentId) + if (children != null) { + LibraryResult.ofItemList(children, params) + } else { + LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE) + } + } + + override fun onAddMediaItems( + mediaSession: MediaSession, + controller: ControllerInfo, + mediaItems: List + ): ListenableFuture> = scope.future { + mediaItems.map { mediaItem -> + mediaItemProvider.item(mediaItem.mediaId) ?: mediaItem + } + } + + override fun onSubscribe( + session: MediaLibrarySession, + browser: ControllerInfo, + parentId: String, + params: LibraryParams? + ): ListenableFuture> = scope.future { + val children = mediaItemProvider.children(parentId) + ?: return@future LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE) + session.notifyChildrenChanged(browser, parentId, children.size, params) + LibraryResult.ofVoid() + } + + override fun onPlaybackResumption( + mediaSession: MediaSession, + controller: ControllerInfo + ): ListenableFuture { + return scope.future { + mediaItemProvider.currentMediaItemsOrKeynote() + } + } +} diff --git a/core/playback/src/main/kotlin/com/droidknights/app2023/core/playback/session/MediaId.kt b/core/playback/src/main/kotlin/com/droidknights/app2023/core/playback/session/MediaId.kt new file mode 100644 index 00000000..aa004f4e --- /dev/null +++ b/core/playback/src/main/kotlin/com/droidknights/app2023/core/playback/session/MediaId.kt @@ -0,0 +1,41 @@ +package com.droidknights.app2023.core.playback.session + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +sealed interface MediaId { + @Serializable + @SerialName("root") + object Root : MediaId + + @Serializable + @SerialName("session") + data class Session(val id: String) : MediaId + + @Serializable + @SerialName("tag") + data class Tag( + val name: String, + ) : MediaId + + @Serializable + @SerialName("track") + sealed interface Track : MediaId { + @Serializable + @SerialName("keynote") + object Keynote : Track + + @Serializable + @SerialName("track-01") + object TrackOne : Track + + @Serializable + @SerialName("track-02") + object TrackTwo : Track + + @Serializable + @SerialName("track-03") + object TrackThree : Track + } +} \ No newline at end of file diff --git a/core/playback/src/main/kotlin/com/droidknights/app2023/core/playback/session/MediaItemBuilder.kt b/core/playback/src/main/kotlin/com/droidknights/app2023/core/playback/session/MediaItemBuilder.kt new file mode 100644 index 00000000..a0b10c53 --- /dev/null +++ b/core/playback/src/main/kotlin/com/droidknights/app2023/core/playback/session/MediaItemBuilder.kt @@ -0,0 +1,53 @@ +package com.droidknights.app2023.core.playback.session + +import android.net.Uri +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json + +internal fun MediaItem( + title: String, + mediaId: MediaId, + isPlayable: Boolean, + browsable: Boolean, + description: String? = null, + album: String? = null, + artist: String? = null, + genre: String? = null, + sourceUri: Uri? = null, + imageUri: Uri? = null, +): MediaItem { + val metadata = + MediaMetadata.Builder() + .setAlbumTitle(album) + .setTitle(title) + .setDescription(description) + .setArtist(artist) + .setGenre(genre) + .setIsBrowsable(browsable) + .setIsPlayable(isPlayable) + .setArtworkUri(imageUri) + .setMediaType( + when (mediaId) { + is MediaId.Session -> MediaMetadata.MEDIA_TYPE_VIDEO + is MediaId.Track -> MediaMetadata.MEDIA_TYPE_PLAYLIST + is MediaId.Tag -> MediaMetadata.MEDIA_TYPE_PLAYLIST + MediaId.Root -> MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS + }, + ) + .build() + + return MediaItem.Builder() + .setMediaId(Json.encodeToString(MediaId.serializer(), mediaId)) + .setMediaMetadata(metadata) + .setUri(sourceUri) + .build() +} + +fun String.toMediaIdOrNull(): MediaId? = + try { + Json.decodeFromString(MediaId.serializer(), this) + } catch (e: SerializationException) { + null + } diff --git a/core/playback/src/main/kotlin/com/droidknights/app2023/core/playback/session/MediaItemProvider.kt b/core/playback/src/main/kotlin/com/droidknights/app2023/core/playback/session/MediaItemProvider.kt new file mode 100644 index 00000000..eae059d1 --- /dev/null +++ b/core/playback/src/main/kotlin/com/droidknights/app2023/core/playback/session/MediaItemProvider.kt @@ -0,0 +1,167 @@ +package com.droidknights.app2023.core.playback.session + +import android.app.Application +import android.net.Uri +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.session.MediaSession.MediaItemsWithStartPosition +import com.droidknights.app2023.core.data.repository.SessionRepository +import com.droidknights.app2023.core.model.Room +import com.droidknights.app2023.core.model.Session +import com.droidknights.app2023.core.playback.R +import javax.inject.Inject + +class MediaItemProvider @Inject constructor( + private val sessionRepository: SessionRepository, + private val application: Application, +) { + + fun root(): MediaItem = MediaItem( + title = application.getString(R.string.media_session_root_title), + description = application.getString(R.string.media_session_root_description), + browsable = true, + isPlayable = false, + mediaId = MediaId.Root, + ) + + suspend fun item(id: String): MediaItem? { + val mediaId = id.toMediaIdOrNull() ?: return null + return when (mediaId) { + MediaId.Root -> root() + is MediaId.Session -> { sessionRepository.getSession(mediaId.id).let(::mediaItem) } + is MediaId.Tag -> { mediaItem(mediaId) } + is MediaId.Track -> { + val sessions = sessionRepository.getSessions() + mediaItem(mediaId, sessions) + } + } + } + + suspend fun children(id: String): List? { + val mediaId = id.toMediaIdOrNull() ?: return null + val sessions = runCatching { sessionRepository.getSessions() }.getOrNull() ?: return null + return when (mediaId) { + MediaId.Root -> { + listOf( + mediaItem(track = MediaId.Track.Keynote, sessions = sessions), + mediaItem(track = MediaId.Track.TrackOne, sessions = sessions), + mediaItem(track = MediaId.Track.TrackTwo, sessions = sessions), + mediaItem(track = MediaId.Track.TrackThree, sessions = sessions), + ) + } + is MediaId.Track.Keynote -> { mediaItems(mediaId, sessions) } + is MediaId.Track -> { mediaItems(mediaId, sessions) } + is MediaId.Tag -> { mediaItems(mediaId, sessions) } + is MediaId.Session -> { + runCatching { sessionRepository.getSession(mediaId.id) }.getOrNull() + ?.let { session -> listOf(mediaItem(session)) } + } + } + } + + private fun mediaItem( + track: MediaId.Track, + sessions: List, + ) = MediaItem( + title = when (track) { + is MediaId.Track.Keynote -> application.getString(R.string.media_session_keynote_title) + is MediaId.Track.TrackOne -> application.getString(R.string.media_session_track_1_title) + is MediaId.Track.TrackTwo -> application.getString(R.string.media_session_track_2_title) + is MediaId.Track.TrackThree -> application.getString(R.string.media_session_track_3_title) + }, + description = when (track) { + is MediaId.Track.Keynote -> application.getString(R.string.media_session_keynote_description) + is MediaId.Track.TrackOne -> application.getString(R.string.media_session_track_1_description) + is MediaId.Track.TrackTwo -> application.getString(R.string.media_session_track_2_description) + is MediaId.Track.TrackThree -> application.getString(R.string.media_session_track_3_description) + }, + mediaId = track, + browsable = true, + isPlayable = false, + imageUri = when (track) { + is MediaId.Track.Keynote -> Uri.parse("https://raw.githubusercontent.com/workspace/media-samples/main/img/logo.jpg") + is MediaId.Track.TrackOne -> Uri.parse("https://raw.githubusercontent.com/workspace/media-samples/main/img/track1.jpg") + is MediaId.Track.TrackTwo -> Uri.parse("https://raw.githubusercontent.com/workspace/media-samples/main/img/track2.jpg") + is MediaId.Track.TrackThree -> Uri.parse("https://raw.githubusercontent.com/workspace/media-samples/main/img/track3.jpg") + }, + artist = room(track).let { room -> + sessions.filter { session -> session.room == room } + .mapNotNull { it.speakers.firstOrNull() } + .joinToString(", ") { it.name } + }, + ) + + private fun mediaItem( + tag: MediaId.Tag + ) = MediaItem( + title = tag.name, + description = "${tag.name}에 관한 발표 목록입니다.", + mediaId = tag, + browsable = true, + isPlayable = false, + imageUri = Uri.parse("https://raw.githubusercontent.com/workspace/media-samples/main/img/logo.jpg"), + ) + + private fun mediaItems( + track: MediaId.Track.Keynote, + sessions: List, + ): List { + return room(track).let { room -> + sessions.filter { session -> session.room == room } + .map(::mediaItem) + } + sessions + .flatMap { session -> session.tags } + .distinctBy { it.name } + .map { tag -> MediaId.Tag(tag.name) } + .map { tag -> mediaItem(tag) } + } + + private fun mediaItems( + track: MediaId.Track, + sessions: List, + ) = room(track).let { room -> + sessions.filter { session -> session.room == room } + .map(::mediaItem) + } + + private fun mediaItems( + tag: MediaId.Tag, + sessions: List, + ) = sessions + .filter { session -> session.tags.any { it.name == tag.name } } + .map(::mediaItem) + + fun mediaItem(session: Session): MediaItem = MediaItem( + title = session.title, + description = session.content, + mediaId = MediaId.Session(session.id), + browsable = false, + isPlayable = true, + sourceUri = session.video?.manifestUrl?.let(Uri::parse), + imageUri = session.speakers.firstOrNull() + ?.imageUrl.takeIf { !it.isNullOrBlank() } + ?.let(Uri::parse) + ?: Uri.parse("https://raw.githubusercontent.com/workspace/media-samples/main/img/logo.jpg"), + artist = session.speakers.joinToString(",") { it.name }, + ) + + suspend fun currentMediaItemsOrKeynote(): MediaItemsWithStartPosition { + val currentPlayingSessionId = sessionRepository.getCurrentPlayingSession()?.id ?: "1" + + val session = sessionRepository.getSession(currentPlayingSessionId) + return MediaItemsWithStartPosition( + listOf(mediaItem(session)), + C.INDEX_UNSET, + C.TIME_UNSET, + ) + } + + private fun room( + track: MediaId.Track + ) = when (track) { + is MediaId.Track.Keynote -> Room.ETC + is MediaId.Track.TrackOne -> Room.TRACK1 + is MediaId.Track.TrackTwo -> Room.TRACK2 + is MediaId.Track.TrackThree -> Room.TRACK3 + } +} diff --git a/core/playback/src/main/kotlin/com/droidknights/app2023/core/playback/session/PlaybackService.kt b/core/playback/src/main/kotlin/com/droidknights/app2023/core/playback/session/PlaybackService.kt new file mode 100644 index 00000000..de969b45 --- /dev/null +++ b/core/playback/src/main/kotlin/com/droidknights/app2023/core/playback/session/PlaybackService.kt @@ -0,0 +1,63 @@ +package com.droidknights.app2023.core.playback.session + +import android.content.Intent +import androidx.media3.common.Player +import androidx.media3.session.MediaLibraryService +import androidx.media3.session.MediaSession +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.cancel +import javax.inject.Inject + +@AndroidEntryPoint +class PlaybackService : MediaLibraryService() { + + @Inject + lateinit var session: MediaLibrarySession + + @Inject + lateinit var scope: CoroutineScope + + @Inject + lateinit var player: Player + + override fun onTaskRemoved(rootIntent: Intent?) { + super.onTaskRemoved(rootIntent) + if (!player.playWhenReady) { + // If the player isn't set to play when ready, the service is stopped and resources released. + // This is done because if the app is swiped away from recent apps without this check, + // the notification would remain in an unresponsive state. + // Further explanation can be found at: https://github.com/androidx/media/issues/167#issuecomment-1615184728 + release() + stopSelf() + } + } + + private fun release() { + player.release() + session.release() + scope.cancel() + } + + override fun onDestroy() { + super.onDestroy() + release() + } + + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession? { + return session.takeUnless { session -> + session.invokeIsReleased + } + } +} + +private val MediaSession.invokeIsReleased: Boolean + get() = try { + // temporarily checked to debug + // https://github.com/androidx/media/issues/422 + MediaSession::class.java.getDeclaredMethod("isReleased") + .apply { isAccessible = true } + .invoke(this) as Boolean + } catch (e: Exception) { + false + } \ No newline at end of file diff --git a/core/playback/src/main/kotlin/com/droidknights/app2023/core/playback/session/SessionActivityIntentProvider.kt b/core/playback/src/main/kotlin/com/droidknights/app2023/core/playback/session/SessionActivityIntentProvider.kt new file mode 100644 index 00000000..c12e6c42 --- /dev/null +++ b/core/playback/src/main/kotlin/com/droidknights/app2023/core/playback/session/SessionActivityIntentProvider.kt @@ -0,0 +1,7 @@ +package com.droidknights.app2023.core.playback.session + +import android.app.PendingIntent + +interface SessionActivityIntentProvider { + fun toPlayer(): PendingIntent? +} \ No newline at end of file diff --git a/core/playback/src/main/res/values/strings.xml b/core/playback/src/main/res/values/strings.xml new file mode 100644 index 00000000..4f86173a --- /dev/null +++ b/core/playback/src/main/res/values/strings.xml @@ -0,0 +1,13 @@ + + + 모든 세션 + DroidKnights 2023 전체 발표 목록 입니다. + Keynote + DroidKnights 2023 Keynote + Track 01 + DroidKnights 2023 Track 01 발표 목록 입니다. + Track 02 + DroidKnights 2023 Track 02 목록 입니다. + Track 03 + DroidKnights 2023 Track 03 목록 입니다. + \ No newline at end of file diff --git a/feature/main/build.gradle.kts b/feature/main/build.gradle.kts index 72d66d50..4eec740e 100644 --- a/feature/main/build.gradle.kts +++ b/feature/main/build.gradle.kts @@ -12,6 +12,7 @@ dependencies { implementation(projects.feature.contributor) implementation(projects.feature.session) implementation(projects.feature.bookmark) + implementation(projects.feature.player) implementation(projects.widget) diff --git a/feature/main/src/main/AndroidManifest.xml b/feature/main/src/main/AndroidManifest.xml index a1479f61..04670d02 100644 --- a/feature/main/src/main/AndroidManifest.xml +++ b/feature/main/src/main/AndroidManifest.xml @@ -14,6 +14,9 @@ + + + diff --git a/feature/main/src/main/java/com/droidknights/app2023/feature/main/MainNavigator.kt b/feature/main/src/main/java/com/droidknights/app2023/feature/main/MainNavigator.kt index ecafd9e4..a7dec530 100644 --- a/feature/main/src/main/java/com/droidknights/app2023/feature/main/MainNavigator.kt +++ b/feature/main/src/main/java/com/droidknights/app2023/feature/main/MainNavigator.kt @@ -1,7 +1,9 @@ package com.droidknights.app2023.feature.main +import android.content.res.Configuration.ORIENTATION_PORTRAIT import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalConfiguration import androidx.navigation.NavDestination import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavHostController @@ -12,6 +14,8 @@ import com.droidknights.app2023.feature.bookmark.navigation.navigateBookmark import com.droidknights.app2023.feature.contributor.navigation.navigateContributor import com.droidknights.app2023.feature.home.navigation.HomeRoute import com.droidknights.app2023.feature.home.navigation.navigateHome +import com.droidknights.app2023.feature.player.navigation.PlayerRoute +import com.droidknights.app2023.feature.player.navigation.navigatePlayer import com.droidknights.app2023.feature.session.navigation.navigateSession import com.droidknights.app2023.feature.session.navigation.navigateSessionDetail import com.droidknights.app2023.feature.setting.navigation.navigateSetting @@ -58,6 +62,10 @@ internal class MainNavigator( navController.navigateSessionDetail(sessionId) } + fun navigatePlayer(sessionId: String) { + navController.navigatePlayer(sessionId) + } + fun popBackStack() { navController.popBackStack() } @@ -76,6 +84,14 @@ internal class MainNavigator( val currentRoute = currentDestination?.route ?: return false return currentRoute in MainTab } + + @Composable + fun shouldShowSystemUI(): Boolean { + val orientation = LocalConfiguration.current.orientation + val currentRoute = currentDestination?.route ?: return true + return !currentRoute.startsWith(PlayerRoute.route) || + orientation == ORIENTATION_PORTRAIT + } } @Composable diff --git a/feature/main/src/main/java/com/droidknights/app2023/feature/main/MainScreen.kt b/feature/main/src/main/java/com/droidknights/app2023/feature/main/MainScreen.kt index dfdb57e2..0008a05b 100644 --- a/feature/main/src/main/java/com/droidknights/app2023/feature/main/MainScreen.kt +++ b/feature/main/src/main/java/com/droidknights/app2023/feature/main/MainScreen.kt @@ -1,5 +1,6 @@ package com.droidknights.app2023.feature.main +import android.app.Activity import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -27,20 +28,26 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat import androidx.navigation.compose.NavHost import com.droidknights.app2023.core.designsystem.theme.Neon01 import com.droidknights.app2023.core.designsystem.theme.surfaceDim import com.droidknights.app2023.feature.bookmark.navigation.bookmarkNavGraph import com.droidknights.app2023.feature.contributor.navigation.contributorNavGraph import com.droidknights.app2023.feature.home.navigation.homeNavGraph +import com.droidknights.app2023.feature.player.navigation.playerNavGraph import com.droidknights.app2023.feature.session.navigation.sessionNavGraph import com.droidknights.app2023.feature.setting.navigation.settingNavGraph import kotlinx.collections.immutable.PersistentList @@ -68,6 +75,21 @@ internal fun MainScreen( } } + val view = LocalView.current + val shouldShowSystemUI = navigator.shouldShowSystemUI() + LaunchedEffect(shouldShowSystemUI) { + val window = (view.context as Activity).window + WindowCompat.getInsetsController(window, view).apply { + systemBarsBehavior = if (shouldShowSystemUI) { + show(WindowInsetsCompat.Type.systemBars()) + WindowInsetsControllerCompat.BEHAVIOR_DEFAULT + } else { + hide(WindowInsetsCompat.Type.systemBars()) + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } + } + } + Scaffold( content = { padding -> Box( @@ -102,8 +124,15 @@ internal fun MainScreen( sessionNavGraph( onBackClick = navigator::popBackStackIfNotHome, onSessionClick = { navigator.navigateSessionDetail(it.id) }, + onShowPlayer = { sessionId -> + navigator.navigatePlayer(sessionId) + }, onShowErrorSnackBar = onShowErrorSnackBar ) + + playerNavGraph( + onBackClick = { navigator.popBackStack() }, + ) } } }, diff --git a/feature/player/.gitignore b/feature/player/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/feature/player/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/player/build.gradle.kts b/feature/player/build.gradle.kts new file mode 100644 index 00000000..7a84837e --- /dev/null +++ b/feature/player/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + id("droidknights.android.feature") +} + +android { + namespace = "com.droidknights.app2023.feature.player" +} + +dependencies { + implementation(projects.core.playback) + implementation(libs.coroutines.guava) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.media3.player) + implementation(libs.androidx.media3.player.session) +} diff --git a/feature/player/src/main/AndroidManifest.xml b/feature/player/src/main/AndroidManifest.xml new file mode 100644 index 00000000..9a40236b --- /dev/null +++ b/feature/player/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + diff --git a/feature/player/src/main/kotlin/com/droidknights/app2023/feature/player/PlayerScreen.kt b/feature/player/src/main/kotlin/com/droidknights/app2023/feature/player/PlayerScreen.kt new file mode 100644 index 00000000..e628ae52 --- /dev/null +++ b/feature/player/src/main/kotlin/com/droidknights/app2023/feature/player/PlayerScreen.kt @@ -0,0 +1,233 @@ +package com.droidknights.app2023.feature.player + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.SkipNext +import androidx.compose.material.icons.filled.SkipPrevious +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.droidknights.app2023.core.designsystem.theme.KnightsTheme +import java.util.Locale + +@Composable +internal fun PlayerScreen( + onBackClick: () -> Unit, + viewModel: PlayerViewModel = hiltViewModel(), +) { + val playerUiState by viewModel.playerUiState.collectAsStateWithLifecycle() + + CompositionLocalProvider( + LocalContentColor provides Color.White + ) { + PlayerContent( + uiState = playerUiState, + onBackClick = onBackClick, + onPrevButtonClick = viewModel::prev, + onPlayPauseButtonClick = viewModel::playPause, + onNextButtonClick = viewModel::next, + onPositionChange = viewModel::setPosition + ) + } +} + +@Composable +private fun PlayerContent( + uiState: PlayerUiState, + onBackClick: () -> Unit, + onPrevButtonClick: () -> Unit, + onPlayPauseButtonClick: () -> Unit, + onNextButtonClick: () -> Unit, + onPositionChange: (Long) -> Unit, +) { + when (uiState) { + is PlayerUiState.Loading -> PlayerLoading() + is PlayerUiState.Success -> Box( + modifier = Modifier + .background(Color.Black) + .fillMaxSize() + .systemBarsPadding() + ) { + PlayerView( + modifier = Modifier + .align(Alignment.Center) + .aspectRatio(uiState.aspectRatio) + ) + Box(modifier = Modifier.fillMaxSize()) { + BackButton( + onClick = onBackClick, + modifier = Modifier.align(Alignment.TopStart) + ) + Row( + modifier = Modifier.align(Alignment.Center), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + PrevButton( + enabled = uiState.hasPrevious, + onClick = onPrevButtonClick + ) + PlayPauseButton( + isPlaying = uiState.isPlaying, + onClick = onPlayPauseButtonClick, + ) + NextButton( + enabled = uiState.hasPrevious, + onClick = onNextButtonClick + ) + } + Row( + modifier = Modifier + .padding(8.dp) + .align(Alignment.BottomCenter), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + PositionText(uiState.position) + PositionSeekBar( + modifier = Modifier.weight(1F), + position = uiState.position, + duration = uiState.duration, + onPositionChange = onPositionChange, + ) + PositionText(uiState.duration) + } + } + } + } +} + +@Composable +private fun PlayerLoading() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } +} + +@Composable +internal fun BackButton( + modifier: Modifier = Modifier, + onClick: () -> Unit +) { + IconButton( + onClick = onClick, + modifier = modifier, + ) { + Icon( + imageVector = Icons.Filled.ArrowBack, + contentDescription = "플레이어 종료", + ) + } +} + +@Composable +internal fun PlayPauseButton( + isPlaying: Boolean, + onClick: () -> Unit +) { + IconButton(onClick = onClick) { + if (isPlaying) { + Icon( + modifier = Modifier.size(48.dp), + imageVector = Icons.Filled.Pause, + contentDescription = "일시정지", + ) + } else { + Icon( + modifier = Modifier.size(48.dp), + imageVector = Icons.Filled.PlayArrow, + contentDescription = "재생", + ) + } + } +} + +@Composable +internal fun PrevButton( + enabled: Boolean, + onClick: () -> Unit +) { + IconButton(enabled = enabled, onClick = onClick) { + Icon( + modifier = Modifier.size(48.dp), + imageVector = Icons.Filled.SkipPrevious, + contentDescription = "이전 세션", + ) + } +} + +@Composable +internal fun NextButton( + enabled: Boolean, + onClick: () -> Unit +) { + IconButton(enabled = enabled, onClick = onClick) { + Icon( + modifier = Modifier.size(48.dp), + imageVector = Icons.Filled.SkipNext, + contentDescription = "다음 세션", + ) + } +} + +@Composable +internal fun PositionText(amount: Long) { + Text( + style = KnightsTheme.typography.labelSmallM, + text = amount.formatAsDuration() + ) +} + +private fun Long.formatAsDuration(): String { + val hours = this / 1000 / 3600 + val minutes = ((this / 1000) % 3600) / 60 + val seconds = (this / 1000) % 60 + + return when { + hours > 0 -> String.format(Locale.getDefault(), "%02d:%02d:%02d", hours, minutes, seconds) + else -> String.format(Locale.getDefault(), "%02d:%02d", minutes, seconds) + } +} + +@Composable +internal fun PositionSeekBar( + modifier: Modifier = Modifier, + position: Long, + duration: Long, + onPositionChange: (Long) -> Unit, +) { + Slider( + modifier = modifier, + value = position.toFloat(), + onValueChange = { + onPositionChange(it.toLong()) + }, + valueRange = 0F..duration.toFloat().coerceAtLeast(0F) + ) +} \ No newline at end of file diff --git a/feature/player/src/main/kotlin/com/droidknights/app2023/feature/player/PlayerUiState.kt b/feature/player/src/main/kotlin/com/droidknights/app2023/feature/player/PlayerUiState.kt new file mode 100644 index 00000000..d3646faa --- /dev/null +++ b/feature/player/src/main/kotlin/com/droidknights/app2023/feature/player/PlayerUiState.kt @@ -0,0 +1,16 @@ +package com.droidknights.app2023.feature.player + +sealed interface PlayerUiState { + + object Loading : PlayerUiState + + data class Success( + val isPlaying: Boolean, + val hasPrevious: Boolean, + val hasNext: Boolean, + val position: Long, + val duration: Long, + val speed: Float, + val aspectRatio: Float + ) : PlayerUiState +} \ No newline at end of file diff --git a/feature/player/src/main/kotlin/com/droidknights/app2023/feature/player/PlayerView.kt b/feature/player/src/main/kotlin/com/droidknights/app2023/feature/player/PlayerView.kt new file mode 100644 index 00000000..59a0cd43 --- /dev/null +++ b/feature/player/src/main/kotlin/com/droidknights/app2023/feature/player/PlayerView.kt @@ -0,0 +1,58 @@ +package com.droidknights.app2023.feature.player + +import android.content.ComponentName +import android.view.SurfaceView +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidView +import androidx.media3.common.Player +import androidx.media3.session.MediaController +import androidx.media3.session.SessionToken +import com.droidknights.app2023.core.playback.session.PlaybackService +import kotlinx.coroutines.guava.await + +@Composable +fun PlayerView( + modifier: Modifier = Modifier +) { + val context = LocalContext.current + var player: Player? by remember { mutableStateOf(null) } + var surfaceView: SurfaceView? by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + player = MediaController + .Builder( + context, + SessionToken(context, ComponentName(context, PlaybackService::class.java)) + ) + .buildAsync() + .await() + } + + DisposableEffect(Unit) { + onDispose { + player?.clearVideoSurfaceView(surfaceView) + } + } + + AndroidView( + factory = { + SurfaceView(it).apply { + surfaceView = this + } + }, + update = { + if (player?.isCommandAvailable(Player.COMMAND_SET_VIDEO_SURFACE) == true) { + player?.setVideoSurfaceView(it) + } + }, + modifier = modifier + ) +} diff --git a/feature/player/src/main/kotlin/com/droidknights/app2023/feature/player/PlayerViewModel.kt b/feature/player/src/main/kotlin/com/droidknights/app2023/feature/player/PlayerViewModel.kt new file mode 100644 index 00000000..56eb63fe --- /dev/null +++ b/feature/player/src/main/kotlin/com/droidknights/app2023/feature/player/PlayerViewModel.kt @@ -0,0 +1,68 @@ +package com.droidknights.app2023.feature.player + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.droidknights.app2023.core.domain.usecase.GetCurrentPlayingSessionUseCase +import com.droidknights.app2023.core.domain.usecase.UpdateCurrentPlayingSessionUseCase +import com.droidknights.app2023.core.playback.PlayerController +import com.droidknights.app2023.core.playback.playstate.PlaybackStateManager +import com.droidknights.app2023.feature.player.navigation.PlayerRoute +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class PlayerViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + getCurrentPlayingSessionUseCase: GetCurrentPlayingSessionUseCase, + updateCurrentPlayingSessionUseCase: UpdateCurrentPlayingSessionUseCase, + private val playbackStateManager: PlaybackStateManager, + private val playerController: PlayerController, +) : ViewModel() { + private val _playerUiState = + MutableStateFlow(PlayerUiState.Loading) + val playerUiState: StateFlow = _playerUiState + + init { + viewModelScope.launch { + val sessionId = savedStateHandle.get(PlayerRoute.argumentName) + .takeIf { !it.isNullOrBlank() } + ?: getCurrentPlayingSessionUseCase()?.id + ?: "1" // 처음부터 재생 + updateCurrentPlayingSessionUseCase(sessionId) + playerController.play() + } + viewModelScope.launch { + playbackStateManager.flow.collect { + _playerUiState.value = PlayerUiState.Success( + it.isPlaying, + it.hasPrevious, + it.hasNext, + it.position, + it.duration, + it.speed, + it.aspectRatio + ) + } + } + } + + fun playPause() { + playerController.playPause() + } + + fun prev() { + playerController.previous() + } + + fun next() { + playerController.next() + } + + fun setPosition(position: Long) { + playerController.setPosition(position) + } +} diff --git a/feature/player/src/main/kotlin/com/droidknights/app2023/feature/player/navigation/PlayerNavigation.kt b/feature/player/src/main/kotlin/com/droidknights/app2023/feature/player/navigation/PlayerNavigation.kt new file mode 100644 index 00000000..3b0c4936 --- /dev/null +++ b/feature/player/src/main/kotlin/com/droidknights/app2023/feature/player/navigation/PlayerNavigation.kt @@ -0,0 +1,41 @@ +package com.droidknights.app2023.feature.player.navigation + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavType +import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import androidx.navigation.navDeepLink +import com.droidknights.app2023.feature.player.PlayerScreen + +fun NavController.navigatePlayer(sessionId: String) { + navigate(PlayerRoute.route(sessionId)) +} + +fun NavGraphBuilder.playerNavGraph( + onBackClick: () -> Unit, +) { + composable( + route = PlayerRoute.route("{${PlayerRoute.argumentName}}"), + arguments = listOf( + navArgument("sessionId") { + type = NavType.StringType + defaultValue = "" + } + ), + deepLinks = listOf( + navDeepLink { uriPattern = PlayerRoute.deepLinkUriPattern } + ), + ) { + PlayerScreen( + onBackClick = onBackClick + ) + } +} + +object PlayerRoute { + const val route = "player" + fun route(sessionId: String = ""): String = "$route?$argumentName=$sessionId" + const val argumentName = "sessionId" + const val deepLinkUriPattern = "droidknights://$route" +} diff --git a/feature/session/src/main/java/com/droidknights/app2023/feature/session/SessionCard.kt b/feature/session/src/main/java/com/droidknights/app2023/feature/session/SessionCard.kt index 98083756..1f415863 100644 --- a/feature/session/src/main/java/com/droidknights/app2023/feature/session/SessionCard.kt +++ b/feature/session/src/main/java/com/droidknights/app2023/feature/session/SessionCard.kt @@ -211,6 +211,7 @@ private fun SessionCardPreview() { startTime = LocalDateTime(2023, 9, 12, 16, 10, 0), endTime = LocalDateTime(2023, 9, 12, 16, 45, 0), room = Room.TRACK1, + video = null, isBookmarked = false, ) diff --git a/feature/session/src/main/java/com/droidknights/app2023/feature/session/SessionDetailScreen.kt b/feature/session/src/main/java/com/droidknights/app2023/feature/session/SessionDetailScreen.kt index ba733624..f7c951b0 100644 --- a/feature/session/src/main/java/com/droidknights/app2023/feature/session/SessionDetailScreen.kt +++ b/feature/session/src/main/java/com/droidknights/app2023/feature/session/SessionDetailScreen.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -14,6 +15,7 @@ import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Divider import androidx.compose.material3.Icon @@ -51,6 +53,7 @@ import com.droidknights.app2023.core.model.Room import com.droidknights.app2023.core.model.Session import com.droidknights.app2023.core.model.Speaker import com.droidknights.app2023.core.model.Tag +import com.droidknights.app2023.core.model.Video import com.droidknights.app2023.widget.sendWidgetUpdateCommand import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.toPersistentList @@ -61,6 +64,7 @@ import kotlinx.datetime.LocalDateTime internal fun SessionDetailScreen( sessionId: String, onBackClick: () -> Unit, + onShowPlayer: () -> Unit, viewModel: SessionDetailViewModel = hiltViewModel(), ) { val scrollState = rememberScrollState() @@ -87,7 +91,12 @@ internal fun SessionDetailScreen( onBackClick = onBackClick ) Box { - SessionDetailContent(uiState = sessionUiState) + SessionDetailContent( + uiState = sessionUiState, + onPlayButtonClick = { + onShowPlayer() + } + ) if (effect is SessionDetailEffect.ShowToastForBookmarkState) { SessionDetailBookmarkStatePopup( bookmarked = (effect as SessionDetailEffect.ShowToastForBookmarkState).bookmarked @@ -129,10 +138,13 @@ private fun SessionDetailTopAppBar( } @Composable -private fun SessionDetailContent(uiState: SessionDetailUiState) { +private fun SessionDetailContent( + uiState: SessionDetailUiState, + onPlayButtonClick: () -> Unit, +) { when (uiState) { is SessionDetailUiState.Loading -> SessionDetailLoading() - is SessionDetailUiState.Success -> SessionDetailContent(uiState.session) + is SessionDetailUiState.Success -> SessionDetailContent(uiState.session, onPlayButtonClick) } } @@ -144,7 +156,10 @@ private fun SessionDetailLoading() { } @Composable -private fun SessionDetailContent(session: Session) { +private fun SessionDetailContent( + session: Session, + onPlayButtonClick: () -> Unit, +) { Column( modifier = Modifier .fillMaxSize() @@ -163,6 +178,17 @@ private fun SessionDetailContent(session: Session) { Spacer(modifier = Modifier.height(40.dp)) SessionDetailSpeaker(session.speakers.first()) + Button( + modifier = Modifier + .padding(top = 16.dp) + .fillMaxWidth(), + enabled = session.video != null, + onClick = onPlayButtonClick + ) { + Text( + if (session.video != null) "재생하기" else "영상 미제공 세션" + ) + } } } @@ -276,7 +302,7 @@ private fun BookmarkToggleButton( private val SampleSessionHasContent = Session( id = "2", - title = "세션 제목은 세션 제목 - 개요 있음", + title = "세션 제목은 세션 제목 - 개요, 영상 있음", content = "세션에 대한 소개와 세션에서의 장단점과 세션을 실제로 사용한 사례와 세션 내용에 대한 QnA 진행", speakers = listOf( Speaker( @@ -290,12 +316,13 @@ private val SampleSessionHasContent = Session( room = Room.TRACK1, startTime = LocalDateTime.parse("2023-09-12T11:00:00.000"), endTime = LocalDateTime.parse("2023-09-12T11:30:00.000"), + video = Video("qwer", "asdf"), isBookmarked = false ) private val SampleSessionNoContent = Session( id = "2", - title = "세션 제목은 세션 제목 - 개요 없음", + title = "세션 제목은 세션 제목 - 개요, 영상 없음", content = "", speakers = listOf( Speaker( @@ -309,7 +336,8 @@ private val SampleSessionNoContent = Session( room = Room.TRACK1, startTime = LocalDateTime.parse("2023-09-12T11:00:00.000"), endTime = LocalDateTime.parse("2023-09-12T11:30:00.000"), - isBookmarked = true + video = null, + isBookmarked = true, ) class SessionDetailContentProvider : PreviewParameterProvider { @@ -339,7 +367,7 @@ private fun SessionDetailContentPreview( @PreviewParameter(SessionDetailContentProvider::class) session: Session ) { KnightsTheme { - SessionDetailContent(session = session) + SessionDetailContent(session = session, onPlayButtonClick = {}) } } diff --git a/feature/session/src/main/java/com/droidknights/app2023/feature/session/navigation/SessionNavigation.kt b/feature/session/src/main/java/com/droidknights/app2023/feature/session/navigation/SessionNavigation.kt index 9200dbf4..8213d4a8 100644 --- a/feature/session/src/main/java/com/droidknights/app2023/feature/session/navigation/SessionNavigation.kt +++ b/feature/session/src/main/java/com/droidknights/app2023/feature/session/navigation/SessionNavigation.kt @@ -20,6 +20,7 @@ fun NavController.navigateSessionDetail(sessionId: String) { fun NavGraphBuilder.sessionNavGraph( onBackClick: () -> Unit, onSessionClick: (Session) -> Unit, + onShowPlayer: (String) -> Unit, onShowErrorSnackBar: (throwable: Throwable?) -> Unit ) { composable(SessionRoute.route) { @@ -41,7 +42,10 @@ fun NavGraphBuilder.sessionNavGraph( val sessionId = navBackStackEntry.arguments?.getString("id") ?: "" SessionDetailScreen( sessionId = sessionId, - onBackClick = onBackClick + onBackClick = onBackClick, + onShowPlayer = { + onShowPlayer(sessionId) + }, ) } } diff --git a/feature/tv-main/.gitignore b/feature/tv-main/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/feature/tv-main/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/tv-main/build.gradle.kts b/feature/tv-main/build.gradle.kts new file mode 100644 index 00000000..431c2182 --- /dev/null +++ b/feature/tv-main/build.gradle.kts @@ -0,0 +1,21 @@ +plugins { + id("droidknights.android.feature") +} + +android { + namespace = "com.droidknights.app2023.feature.tvmain" +} + +dependencies { + implementation(projects.feature.tvSession) + implementation(projects.feature.player) + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.lifecycle.runtimeCompose) + implementation(libs.androidx.lifecycle.viewModelCompose) + + implementation(libs.androidx.compose.tv.foundation) + implementation(libs.androidx.compose.tv.material) +} diff --git a/feature/tv-main/src/main/AndroidManifest.xml b/feature/tv-main/src/main/AndroidManifest.xml new file mode 100644 index 00000000..e5571f43 --- /dev/null +++ b/feature/tv-main/src/main/AndroidManifest.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + diff --git a/feature/tv-main/src/main/kotlin/com/droidknights/app2023/feature/tvmain/TvMainActivity.kt b/feature/tv-main/src/main/kotlin/com/droidknights/app2023/feature/tvmain/TvMainActivity.kt new file mode 100644 index 00000000..9600ce64 --- /dev/null +++ b/feature/tv-main/src/main/kotlin/com/droidknights/app2023/feature/tvmain/TvMainActivity.kt @@ -0,0 +1,19 @@ +package com.droidknights.app2023.feature.tvmain + +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import com.droidknights.app2023.core.designsystem.theme.KnightsTheme +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class TvMainActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + KnightsTheme(darkTheme = true) { + TvMainScreen() + } + } + } +} diff --git a/feature/tv-main/src/main/kotlin/com/droidknights/app2023/feature/tvmain/TvMainNavigator.kt b/feature/tv-main/src/main/kotlin/com/droidknights/app2023/feature/tvmain/TvMainNavigator.kt new file mode 100644 index 00000000..0b5fb112 --- /dev/null +++ b/feature/tv-main/src/main/kotlin/com/droidknights/app2023/feature/tvmain/TvMainNavigator.kt @@ -0,0 +1,29 @@ +package com.droidknights.app2023.feature.tvmain + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.navigation.NavHostController +import androidx.navigation.compose.rememberNavController +import com.droidknights.app2023.feature.player.navigation.navigatePlayer +import com.droidknights.app2023.feature.tvsession.navigation.TvSessionRoute + +internal class TvMainNavigator( + val navController: NavHostController, +) { + val startDestination = TvSessionRoute.route + + fun navigatePlayer(sessionId: String) { + navController.navigatePlayer(sessionId) + } + + fun popBackStack() { + navController.popBackStack() + } +} + +@Composable +internal fun rememberTvMainNavigator( + navController: NavHostController = rememberNavController(), +): TvMainNavigator = remember(navController) { + TvMainNavigator(navController) +} diff --git a/feature/tv-main/src/main/kotlin/com/droidknights/app2023/feature/tvmain/TvMainScreen.kt b/feature/tv-main/src/main/kotlin/com/droidknights/app2023/feature/tvmain/TvMainScreen.kt new file mode 100644 index 00000000..ea9254b0 --- /dev/null +++ b/feature/tv-main/src/main/kotlin/com/droidknights/app2023/feature/tvmain/TvMainScreen.kt @@ -0,0 +1,34 @@ +package com.droidknights.app2023.feature.tvmain + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.navigation.compose.NavHost +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.NonInteractiveSurfaceDefaults +import androidx.tv.material3.Surface +import com.droidknights.app2023.feature.player.navigation.playerNavGraph +import com.droidknights.app2023.feature.tvsession.navigation.tvSessionNavGraph + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +internal fun TvMainScreen( + navigator: TvMainNavigator = rememberTvMainNavigator() +) { + Surface( + colors = NonInteractiveSurfaceDefaults.colors( + containerColor = MaterialTheme.colorScheme.background + ) + ) { + NavHost( + navController = navigator.navController, + startDestination = navigator.startDestination, + ) { + tvSessionNavGraph( + onSessionClick = { navigator.navigatePlayer(it.id) }, + ) + playerNavGraph( + onBackClick = { navigator.popBackStack() }, + ) + } + } +} diff --git a/feature/tv-session/.gitignore b/feature/tv-session/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/feature/tv-session/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/tv-session/build.gradle.kts b/feature/tv-session/build.gradle.kts new file mode 100644 index 00000000..a98a7e8e --- /dev/null +++ b/feature/tv-session/build.gradle.kts @@ -0,0 +1,14 @@ +plugins { + id("droidknights.android.feature") +} + +android { + namespace = "com.droidknights.app2023.feature.tvsession" +} + +dependencies { + implementation(libs.kotlinx.immutable) + + implementation(libs.androidx.compose.tv.foundation) + implementation(libs.androidx.compose.tv.material) +} diff --git a/feature/tv-session/src/main/AndroidManifest.xml b/feature/tv-session/src/main/AndroidManifest.xml new file mode 100644 index 00000000..a5918e68 --- /dev/null +++ b/feature/tv-session/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature/tv-session/src/main/kotlin/com/droidknights/app2023/feature/tvsession/TvSessionCard.kt b/feature/tv-session/src/main/kotlin/com/droidknights/app2023/feature/tvsession/TvSessionCard.kt new file mode 100644 index 00000000..b2ba61ad --- /dev/null +++ b/feature/tv-session/src/main/kotlin/com/droidknights/app2023/feature/tvsession/TvSessionCard.kt @@ -0,0 +1,165 @@ +package com.droidknights.app2023.feature.tvsession + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.droidknights.app2023.core.designsystem.component.NetworkImage +import com.droidknights.app2023.core.designsystem.component.TextChip +import com.droidknights.app2023.core.designsystem.theme.DarkGray +import com.droidknights.app2023.core.designsystem.theme.KnightsTheme +import com.droidknights.app2023.core.designsystem.theme.LightGray +import com.droidknights.app2023.core.model.Level +import com.droidknights.app2023.core.model.Room +import com.droidknights.app2023.core.model.Session +import com.droidknights.app2023.core.model.Speaker +import com.droidknights.app2023.core.model.Tag +import com.droidknights.app2023.feature.tvsession.component.KnightsCard +import kotlinx.datetime.LocalDateTime + +@Composable +internal fun SessionCard( + session: Session, + modifier: Modifier = Modifier, + onSessionClick: (Session) -> Unit = { }, +) { + if (session.video != null) { + KnightsCard( + modifier = modifier, + onClick = { onSessionClick(session) } + ) { + SessionCardContent(session = session) + } + } else { + KnightsCard( + modifier = modifier + ) { + SessionCardContent(session = session) + } + } +} + +@Composable +private fun SessionCardContent( + session: Session, +) { + Column( + modifier = Modifier.padding(CardContentPadding) + ) { + // 카테고리 + Row(verticalAlignment = Alignment.CenterVertically) { + CategoryChip() + session.tags.forEach { tag -> + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = tag.name, + style = KnightsTheme.typography.labelLargeM, + color = DarkGray, + ) + } + } + + // 제목 + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = session.title, + style = KnightsTheme.typography.titleLargeB, + color = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.padding(end = 50.dp) + ) + + // 트랙 + Spacer(modifier = Modifier.height(12.dp)) + Row { + TrackChip(room = session.room) + Spacer(modifier = Modifier.width(8.dp)) + TimeChip(dateTime = session.startTime) + } + + // 발표자 + Spacer(modifier = Modifier.height(12.dp)) + Box(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.align(Alignment.BottomStart)) { + session.speakers.forEach { speaker -> + Text( + text = speaker.name, + style = KnightsTheme.typography.titleLargeB, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + } + Row( + modifier = Modifier.align(Alignment.BottomEnd) + ) { + session.speakers.forEach { speaker -> + NetworkImage( + imageUrl = speaker.imageUrl, + modifier = Modifier + .size(80.dp) + .clip(CircleShape), + placeholder = painterResource(id = com.droidknights.app2023.core.ui.R.drawable.placeholder_speaker), + ) + } + } + } + } +} + +@Composable +private fun CategoryChip() { + TextChip( + text = stringResource(id = R.string.session_category), + containerColor = DarkGray, + labelColor = LightGray, + ) +} + +private val CardContentPadding = + PaddingValues(start = 24.dp, top = 16.dp, end = 24.dp, bottom = 24.dp) + +@Preview +@Composable +private fun SessionCardPreview() { + val fakeSession = Session( + id = "1", + title = "Jetpack Compose에 있는 것, 없는 것", + content = "", + speakers = listOf( + Speaker( + name = "안성용", + introduction = "안드로이드 개발자", + imageUrl = "https://picsum.photos/200", + ), + ), + level = Level.BASIC, + tags = listOf( + Tag("효율적인 코드베이스") + ), + startTime = LocalDateTime(2023, 9, 12, 16, 10, 0), + endTime = LocalDateTime(2023, 9, 12, 16, 45, 0), + room = Room.TRACK1, + video = null, + isBookmarked = true, + ) + + KnightsTheme { + SessionCard(fakeSession) + } +} diff --git a/feature/tv-session/src/main/kotlin/com/droidknights/app2023/feature/tvsession/TvSessionChip.kt b/feature/tv-session/src/main/kotlin/com/droidknights/app2023/feature/tvsession/TvSessionChip.kt new file mode 100644 index 00000000..6a2da0ad --- /dev/null +++ b/feature/tv-session/src/main/kotlin/com/droidknights/app2023/feature/tvsession/TvSessionChip.kt @@ -0,0 +1,34 @@ +package com.droidknights.app2023.feature.tvsession + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.res.stringResource +import com.droidknights.app2023.core.designsystem.component.TextChip +import com.droidknights.app2023.core.model.Room +import com.droidknights.app2023.core.ui.textRes +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.toJavaLocalDateTime +import java.time.format.DateTimeFormatter + +@Composable +internal fun TrackChip(room: Room) { + TextChip( + text = stringResource(id = room.textRes), + containerColor = MaterialTheme.colorScheme.secondaryContainer, + labelColor = MaterialTheme.colorScheme.onSecondaryContainer, + ) +} + +@Composable +internal fun TimeChip(dateTime: LocalDateTime) { + val pattern = stringResource(id = R.string.session_time_fmt) + val formatter = remember { DateTimeFormatter.ofPattern(pattern) } + val time = remember { dateTime.toJavaLocalDateTime().toLocalTime() } + + TextChip( + text = formatter.format(time), + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + labelColor = MaterialTheme.colorScheme.onTertiaryContainer, + ) +} diff --git a/feature/tv-session/src/main/kotlin/com/droidknights/app2023/feature/tvsession/TvSessionScreen.kt b/feature/tv-session/src/main/kotlin/com/droidknights/app2023/feature/tvsession/TvSessionScreen.kt new file mode 100644 index 00000000..9c962dbb --- /dev/null +++ b/feature/tv-session/src/main/kotlin/com/droidknights/app2023/feature/tvsession/TvSessionScreen.kt @@ -0,0 +1,125 @@ +package com.droidknights.app2023.feature.tvsession + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Divider +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.layout +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.tv.foundation.lazy.list.TvLazyColumn +import androidx.tv.foundation.lazy.list.TvLazyListScope +import androidx.tv.foundation.lazy.list.TvLazyRow +import androidx.tv.foundation.lazy.list.items +import com.droidknights.app2023.core.designsystem.theme.KnightsTheme +import com.droidknights.app2023.core.model.Room +import com.droidknights.app2023.core.model.Session +import com.droidknights.app2023.core.ui.RoomText +import kotlinx.collections.immutable.PersistentList + +@Composable +internal fun TvSessionScreen( + onSessionClick: (Session) -> Unit, + tvSessionViewModel: TvSessionViewModel = hiltViewModel(), +) { + val tvSessionUiState by tvSessionViewModel.uiState.collectAsStateWithLifecycle() + + TvSessionContent( + tvSessionUiState = tvSessionUiState, + modifier = Modifier.fillMaxSize(), + onSessionClick = onSessionClick, + ) +} + +@Composable +private fun TvSessionContent( + tvSessionUiState: TvSessionUiState, + onSessionClick: (Session) -> Unit, + modifier: Modifier = Modifier, +) { + when (tvSessionUiState) { + TvSessionUiState.Loading -> Box(modifier, contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + is TvSessionUiState.Sessions -> TvLazyColumn( + modifier = modifier, + contentPadding = PaddingValues(32.dp), + verticalArrangement = Arrangement.spacedBy(32.dp), + ) { + sessionItems( + items = tvSessionUiState.sessions, + onSessionClick = onSessionClick, + ) + } + } +} + +private fun TvLazyListScope.sessionItems( + items: PersistentList, + onSessionClick: (Session) -> Unit, +) { + items.groupBy { it.room }.entries.forEach { (room, sessions) -> + item { + Column(verticalArrangement = Arrangement.spacedBy(32.dp)) { + RoomTitle( + modifier = Modifier.fillMaxWidth(), + room = room + ) + TvLazyRow( + modifier = Modifier + // https://github.com/android/nowinandroid/blob/main/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt#L179-L191 + .layout { measurable, constraints -> + val placeable = measurable.measure( + constraints.copy( + maxWidth = constraints.maxWidth + 64.dp.roundToPx(), + ), + ) + layout(placeable.width, placeable.height) { + placeable.place(0, 0) + } + } + .fillMaxWidth(), + contentPadding = PaddingValues(horizontal = 32.dp), + horizontalArrangement = Arrangement.spacedBy(32.dp) + ) { + items(sessions) { session -> + SessionCard( + modifier = Modifier.width(480.dp), + session = session, + onSessionClick = onSessionClick + ) + } + } + } + } + } +} + +@Composable +private fun RoomTitle( + modifier: Modifier = Modifier, + room: Room, +) { + Column(modifier = modifier) { + RoomText( + room = room, + style = KnightsTheme.typography.titleLargeB, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + Spacer(modifier = Modifier.height(8.dp)) + Divider(thickness = 2.dp, color = MaterialTheme.colorScheme.onPrimaryContainer) + } +} diff --git a/feature/tv-session/src/main/kotlin/com/droidknights/app2023/feature/tvsession/TvSessionUiState.kt b/feature/tv-session/src/main/kotlin/com/droidknights/app2023/feature/tvsession/TvSessionUiState.kt new file mode 100644 index 00000000..6a1c5ab9 --- /dev/null +++ b/feature/tv-session/src/main/kotlin/com/droidknights/app2023/feature/tvsession/TvSessionUiState.kt @@ -0,0 +1,12 @@ +package com.droidknights.app2023.feature.tvsession + +import com.droidknights.app2023.core.model.Session +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.persistentListOf + +sealed interface TvSessionUiState { + object Loading : TvSessionUiState + data class Sessions( + val sessions: PersistentList = persistentListOf(), + ) : TvSessionUiState +} diff --git a/feature/tv-session/src/main/kotlin/com/droidknights/app2023/feature/tvsession/TvSessionViewModel.kt b/feature/tv-session/src/main/kotlin/com/droidknights/app2023/feature/tvsession/TvSessionViewModel.kt new file mode 100644 index 00000000..b39c9d4b --- /dev/null +++ b/feature/tv-session/src/main/kotlin/com/droidknights/app2023/feature/tvsession/TvSessionViewModel.kt @@ -0,0 +1,34 @@ +package com.droidknights.app2023.feature.tvsession + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.droidknights.app2023.core.domain.usecase.GetSessionsUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject + +@HiltViewModel +class TvSessionViewModel @Inject constructor( + private val getSessionsUseCase: GetSessionsUseCase, +) : ViewModel() { + + private val _errorFlow = MutableSharedFlow() + val errorFlow: SharedFlow get() = _errorFlow + + val uiState: StateFlow = flow { emit(getSessionsUseCase().toPersistentList()) } + .map(TvSessionUiState::Sessions) + .catch { throwable -> _errorFlow.emit(throwable) } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = TvSessionUiState.Loading + ) +} diff --git a/feature/tv-session/src/main/kotlin/com/droidknights/app2023/feature/tvsession/component/TvCard.kt b/feature/tv-session/src/main/kotlin/com/droidknights/app2023/feature/tvsession/component/TvCard.kt new file mode 100644 index 00000000..e823e240 --- /dev/null +++ b/feature/tv-session/src/main/kotlin/com/droidknights/app2023/feature/tvsession/component/TvCard.kt @@ -0,0 +1,66 @@ +package com.droidknights.app2023.feature.tvsession.component + +import android.content.res.Configuration +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.tv.material3.ClickableSurfaceDefaults +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.NonInteractiveSurfaceDefaults +import androidx.tv.material3.Surface +import com.droidknights.app2023.core.designsystem.theme.KnightsTheme + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun KnightsCard( + modifier: Modifier = Modifier, + color: Color = MaterialTheme.colorScheme.surface, + content: @Composable BoxScope.() -> Unit, +) { + Surface( + modifier = modifier.fillMaxWidth(), + colors = NonInteractiveSurfaceDefaults.colors(containerColor = color), + shape = RoundedCornerShape(32.dp), + tonalElevation = 2.dp, + content = content, + ) +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun KnightsCard( + modifier: Modifier = Modifier, + enabled: Boolean = true, + onClick: () -> Unit = {}, + color: Color = MaterialTheme.colorScheme.surface, + content: @Composable BoxScope.() -> Unit, +) { + Surface( + onClick = onClick, + enabled = enabled, + modifier = modifier.fillMaxWidth(), + colors = ClickableSurfaceDefaults.colors( + containerColor = color, + focusedContainerColor = MaterialTheme.colorScheme.inversePrimary + ), + shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(32.dp)), + tonalElevation = 2.dp, + content = content, + ) +} + +@Composable +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +private fun KnightsCardPreview() { + KnightsTheme { + KnightsCard(modifier = Modifier.size(320.dp, 160.dp), content = { }) + } +} diff --git a/feature/tv-session/src/main/kotlin/com/droidknights/app2023/feature/tvsession/navigation/TvSessionNavigation.kt b/feature/tv-session/src/main/kotlin/com/droidknights/app2023/feature/tvsession/navigation/TvSessionNavigation.kt new file mode 100644 index 00000000..5acd4a16 --- /dev/null +++ b/feature/tv-session/src/main/kotlin/com/droidknights/app2023/feature/tvsession/navigation/TvSessionNavigation.kt @@ -0,0 +1,23 @@ +package com.droidknights.app2023.feature.tvsession.navigation + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.droidknights.app2023.core.model.Session +import com.droidknights.app2023.feature.tvsession.TvSessionScreen + +fun NavController.navigateTvSession() { + navigate(TvSessionRoute.route) +} + +fun NavGraphBuilder.tvSessionNavGraph( + onSessionClick: (Session) -> Unit, +) { + composable(TvSessionRoute.route) { + TvSessionScreen(onSessionClick = onSessionClick) + } +} + +object TvSessionRoute { + const val route: String = "tv-session" +} diff --git a/feature/tv-session/src/main/res/values/strings.xml b/feature/tv-session/src/main/res/values/strings.xml new file mode 100644 index 00000000..106c8f37 --- /dev/null +++ b/feature/tv-session/src/main/res/values/strings.xml @@ -0,0 +1,7 @@ + + + 세션 목록 + + 카테고리 + HH:mm 발표 + diff --git a/feature/wear-main/.gitignore b/feature/wear-main/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/feature/wear-main/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/wear-main/build.gradle.kts b/feature/wear-main/build.gradle.kts new file mode 100644 index 00000000..3871b68d --- /dev/null +++ b/feature/wear-main/build.gradle.kts @@ -0,0 +1,22 @@ +plugins { + id("droidknights.android.feature") +} + +android { + namespace = "com.droidknights.app2023.feature.wearmain" +} + +dependencies { + implementation(projects.feature.wearSession) + implementation(projects.feature.wearPlayer) + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.lifecycle.runtimeCompose) + implementation(libs.androidx.lifecycle.viewModelCompose) + + implementation(libs.androidx.compose.wear.foundation) + implementation(libs.androidx.compose.wear.material) + implementation(libs.androidx.compose.wear.navigation) +} diff --git a/feature/wear-main/src/main/AndroidManifest.xml b/feature/wear-main/src/main/AndroidManifest.xml new file mode 100644 index 00000000..860cdb04 --- /dev/null +++ b/feature/wear-main/src/main/AndroidManifest.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + diff --git a/feature/wear-main/src/main/kotlin/com/droidknights/app2023/feature/wearmain/WearMainActivity.kt b/feature/wear-main/src/main/kotlin/com/droidknights/app2023/feature/wearmain/WearMainActivity.kt new file mode 100644 index 00000000..2e993d5d --- /dev/null +++ b/feature/wear-main/src/main/kotlin/com/droidknights/app2023/feature/wearmain/WearMainActivity.kt @@ -0,0 +1,19 @@ +package com.droidknights.app2023.feature.wearmain + +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import com.droidknights.app2023.core.designsystem.theme.KnightsTheme +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class WearMainActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + KnightsTheme(darkTheme = true) { + WearMainScreen() + } + } + } +} diff --git a/feature/wear-main/src/main/kotlin/com/droidknights/app2023/feature/wearmain/WearMainNavigator.kt b/feature/wear-main/src/main/kotlin/com/droidknights/app2023/feature/wearmain/WearMainNavigator.kt new file mode 100644 index 00000000..948d2422 --- /dev/null +++ b/feature/wear-main/src/main/kotlin/com/droidknights/app2023/feature/wearmain/WearMainNavigator.kt @@ -0,0 +1,25 @@ +package com.droidknights.app2023.feature.wearmain + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.navigation.NavHostController +import androidx.wear.compose.navigation.rememberSwipeDismissableNavController +import com.droidknights.app2023.feature.wearplayer.navigation.navigateWearPlayer +import com.droidknights.app2023.feature.wearsession.navigation.WearSessionRoute + +internal class WearMainNavigator( + val navController: NavHostController, +) { + val startDestination = WearSessionRoute.route + + fun navigateWearPlayer(sessionId: String) { + navController.navigateWearPlayer(sessionId) + } +} + +@Composable +internal fun rememberWearMainNavigator( + navController: NavHostController = rememberSwipeDismissableNavController(), +): WearMainNavigator = remember(navController) { + WearMainNavigator(navController) +} diff --git a/feature/wear-main/src/main/kotlin/com/droidknights/app2023/feature/wearmain/WearMainScreen.kt b/feature/wear-main/src/main/kotlin/com/droidknights/app2023/feature/wearmain/WearMainScreen.kt new file mode 100644 index 00000000..25d7845e --- /dev/null +++ b/feature/wear-main/src/main/kotlin/com/droidknights/app2023/feature/wearmain/WearMainScreen.kt @@ -0,0 +1,26 @@ +package com.droidknights.app2023.feature.wearmain + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.wear.compose.navigation.SwipeDismissableNavHost +import com.droidknights.app2023.feature.wearplayer.navigation.wearPlayerNavGraph +import com.droidknights.app2023.feature.wearsession.navigation.wearSessionNavGraph + +@Composable +internal fun WearMainScreen( + navigator: WearMainNavigator = rememberWearMainNavigator() +) { + Surface(color = MaterialTheme.colorScheme.background) { + // https://developer.android.com/training/wearables/compose/navigation + SwipeDismissableNavHost( + navController = navigator.navController, + startDestination = navigator.startDestination, + ) { + wearSessionNavGraph( + onSessionClick = { navigator.navigateWearPlayer(it.id) }, + ) + wearPlayerNavGraph() + } + } +} diff --git a/feature/wear-player/.gitignore b/feature/wear-player/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/feature/wear-player/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/wear-player/build.gradle.kts b/feature/wear-player/build.gradle.kts new file mode 100644 index 00000000..eb2c7cfd --- /dev/null +++ b/feature/wear-player/build.gradle.kts @@ -0,0 +1,14 @@ +plugins { + id("droidknights.android.feature") +} + +android { + namespace = "com.droidknights.app2023.feature.wearplayer" +} + +dependencies { + implementation(projects.core.playback) + implementation(libs.androidx.compose.wear.foundation) + implementation(libs.androidx.compose.wear.material) + implementation(libs.androidx.compose.wear.navigation) +} diff --git a/feature/wear-player/src/main/AndroidManifest.xml b/feature/wear-player/src/main/AndroidManifest.xml new file mode 100644 index 00000000..9a40236b --- /dev/null +++ b/feature/wear-player/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + diff --git a/feature/wear-player/src/main/kotlin/com/droidknights/app2023/feature/wearplayer/WearPlayerScreen.kt b/feature/wear-player/src/main/kotlin/com/droidknights/app2023/feature/wearplayer/WearPlayerScreen.kt new file mode 100644 index 00000000..617d62f7 --- /dev/null +++ b/feature/wear-player/src/main/kotlin/com/droidknights/app2023/feature/wearplayer/WearPlayerScreen.kt @@ -0,0 +1,202 @@ +package com.droidknights.app2023.feature.wearplayer + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.FastForward +import androidx.compose.material.icons.filled.FastRewind +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.droidknights.app2023.core.designsystem.component.NetworkImage +import com.droidknights.app2023.core.designsystem.theme.KnightsTheme +import java.util.Locale + +@Composable +internal fun WearPlayerScreen( + viewModel: WearPlayerViewModel = hiltViewModel(), +) { + val playerUiState by viewModel.playerUiState.collectAsStateWithLifecycle() + + CompositionLocalProvider( + LocalContentColor provides Color.White + ) { + PlayerContent( + uiState = playerUiState, + onRewindButtonClick = viewModel::rewind, + onPlayPauseButtonClick = viewModel::playPause, + onFastForwardButtonClick = viewModel::fastForward, + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun PlayerContent( + uiState: WearPlayerUiState, + onRewindButtonClick: () -> Unit, + onPlayPauseButtonClick: () -> Unit, + onFastForwardButtonClick: () -> Unit, +) { + when (uiState) { + is WearPlayerUiState.Loading -> PlayerLoading() + is WearPlayerUiState.Success -> Box( + modifier = Modifier + .background(Color.Black) + .fillMaxSize() + ) { + NetworkImage( + modifier = Modifier.fillMaxSize(), + imageUrl = uiState.artworkUri?.toString() + ) + Column( + modifier = Modifier + .background(Color.Black.copy(alpha = 0.6F)) + .fillMaxSize(), + verticalArrangement = Arrangement.SpaceAround, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = uiState.artist ?: "알 수 없음", + modifier = Modifier + .fillMaxWidth() + .basicMarquee() + .padding(horizontal = 16.dp), + style = KnightsTheme.typography.titleMediumR, + textAlign = TextAlign.Center + ) + Text( + text = uiState.title ?: "제목 없음", + modifier = Modifier + .fillMaxWidth() + .basicMarquee() + .padding(horizontal = 16.dp), + style = KnightsTheme.typography.titleLargeB, + textAlign = TextAlign.Center + ) + } + Row( + modifier = Modifier, + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RewindButton( + onClick = onRewindButtonClick + ) + PlayPauseButton( + isPlaying = uiState.isPlaying, + onClick = onPlayPauseButtonClick, + ) + FastForwardButton( + onClick = onFastForwardButtonClick + ) + } + PositionText(uiState.position, uiState.duration) + } + } + } +} + +@Composable +private fun PlayerLoading() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } +} + +@Composable +internal fun PlayPauseButton( + isPlaying: Boolean, + onClick: () -> Unit +) { + IconButton(onClick = onClick) { + if (isPlaying) { + Icon( + modifier = Modifier.size(48.dp), + imageVector = Icons.Filled.Pause, + contentDescription = "일시 정지", + ) + } else { + Icon( + modifier = Modifier.size(48.dp), + imageVector = Icons.Filled.PlayArrow, + contentDescription = "재생", + ) + } + } +} + +@Composable +internal fun RewindButton( + onClick: () -> Unit +) { + IconButton(onClick = onClick) { + Icon( + modifier = Modifier.size(48.dp), + imageVector = Icons.Filled.FastRewind, + contentDescription = "되감기", + ) + } +} + +@Composable +internal fun FastForwardButton( + onClick: () -> Unit +) { + IconButton(onClick = onClick) { + Icon( + modifier = Modifier.size(48.dp), + imageVector = Icons.Filled.FastForward, + contentDescription = "빨리 감기", + ) + } +} + +@Composable +internal fun PositionText(position: Long, duration: Long) { + Text( + style = KnightsTheme.typography.bodyMediumR, + text = "${position.formatAsDuration()} / ${duration.formatAsDuration()}" + ) +} + +private fun Long.formatAsDuration(): String { + val hours = this / 1000 / 3600 + val minutes = ((this / 1000) % 3600) / 60 + val seconds = (this / 1000) % 60 + + return when { + hours > 0 -> String.format(Locale.getDefault(), "%02d:%02d:%02d", hours, minutes, seconds) + else -> String.format(Locale.getDefault(), "%02d:%02d", minutes, seconds) + } +} \ No newline at end of file diff --git a/feature/wear-player/src/main/kotlin/com/droidknights/app2023/feature/wearplayer/WearPlayerUiState.kt b/feature/wear-player/src/main/kotlin/com/droidknights/app2023/feature/wearplayer/WearPlayerUiState.kt new file mode 100644 index 00000000..be3b8621 --- /dev/null +++ b/feature/wear-player/src/main/kotlin/com/droidknights/app2023/feature/wearplayer/WearPlayerUiState.kt @@ -0,0 +1,21 @@ +package com.droidknights.app2023.feature.wearplayer + +import android.net.Uri + +sealed interface WearPlayerUiState { + + object Loading : WearPlayerUiState + + data class Success( + val isPlaying: Boolean, + val hasPrevious: Boolean, + val hasNext: Boolean, + val position: Long, + val duration: Long, + val speed: Float, + val aspectRatio: Float, + val title: String?, + val artist: String?, + val artworkUri: Uri?, + ) : WearPlayerUiState +} \ No newline at end of file diff --git a/feature/wear-player/src/main/kotlin/com/droidknights/app2023/feature/wearplayer/WearPlayerViewModel.kt b/feature/wear-player/src/main/kotlin/com/droidknights/app2023/feature/wearplayer/WearPlayerViewModel.kt new file mode 100644 index 00000000..22027d30 --- /dev/null +++ b/feature/wear-player/src/main/kotlin/com/droidknights/app2023/feature/wearplayer/WearPlayerViewModel.kt @@ -0,0 +1,67 @@ +package com.droidknights.app2023.feature.wearplayer + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.droidknights.app2023.core.domain.usecase.GetCurrentPlayingSessionUseCase +import com.droidknights.app2023.core.domain.usecase.UpdateCurrentPlayingSessionUseCase +import com.droidknights.app2023.core.playback.PlayerController +import com.droidknights.app2023.core.playback.playstate.PlaybackStateManager +import com.droidknights.app2023.feature.wearplayer.navigation.WearPlayerRoute +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class WearPlayerViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + getCurrentPlayingSessionUseCase: GetCurrentPlayingSessionUseCase, + updateCurrentPlayingSessionUseCase: UpdateCurrentPlayingSessionUseCase, + private val playbackStateManager: PlaybackStateManager, + private val playerController: PlayerController, +) : ViewModel() { + private val _playerUiState = + MutableStateFlow(WearPlayerUiState.Loading) + val playerUiState: StateFlow = _playerUiState + + init { + viewModelScope.launch { + val sessionId = savedStateHandle.get(WearPlayerRoute.argumentName) + .takeIf { !it.isNullOrBlank() } + ?: getCurrentPlayingSessionUseCase()?.id + ?: "1" // 처음부터 재생 + updateCurrentPlayingSessionUseCase(sessionId) + playerController.play() + } + viewModelScope.launch { + playbackStateManager.flow.collect { + _playerUiState.value = WearPlayerUiState.Success( + it.isPlaying, + it.hasPrevious, + it.hasNext, + it.position, + it.duration, + it.speed, + it.aspectRatio, + it.title, + it.artist, + it.artworkUri, + ) + } + } + } + + fun playPause() { + playerController.playPause() + } + + fun rewind() { + playerController.rewind() + } + + fun fastForward() { + playerController.fastForward() + } +} diff --git a/feature/wear-player/src/main/kotlin/com/droidknights/app2023/feature/wearplayer/navigation/WearPlayerNavigation.kt b/feature/wear-player/src/main/kotlin/com/droidknights/app2023/feature/wearplayer/navigation/WearPlayerNavigation.kt new file mode 100644 index 00000000..5cfd4418 --- /dev/null +++ b/feature/wear-player/src/main/kotlin/com/droidknights/app2023/feature/wearplayer/navigation/WearPlayerNavigation.kt @@ -0,0 +1,37 @@ +package com.droidknights.app2023.feature.wearplayer.navigation + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavType +import androidx.navigation.navArgument +import androidx.navigation.navDeepLink +import androidx.wear.compose.navigation.composable +import com.droidknights.app2023.feature.wearplayer.WearPlayerScreen + +fun NavController.navigateWearPlayer(sessionId: String) { + navigate(WearPlayerRoute.route(sessionId)) +} + +fun NavGraphBuilder.wearPlayerNavGraph() { + composable( + route = WearPlayerRoute.route("{${WearPlayerRoute.argumentName}}"), + arguments = listOf( + navArgument("sessionId") { + type = NavType.StringType + defaultValue = "" + } + ), + deepLinks = listOf( + navDeepLink { uriPattern = WearPlayerRoute.deepLinkUriPattern } + ), + ) { + WearPlayerScreen() + } +} + +object WearPlayerRoute { + const val route = "wear-player" + fun route(sessionId: String = ""): String = "$route?$argumentName=$sessionId" + const val argumentName = "sessionId" + const val deepLinkUriPattern = "droidknights://$route" +} diff --git a/feature/wear-session/.gitignore b/feature/wear-session/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/feature/wear-session/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/wear-session/build.gradle.kts b/feature/wear-session/build.gradle.kts new file mode 100644 index 00000000..27029f0a --- /dev/null +++ b/feature/wear-session/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + id("droidknights.android.feature") +} + +android { + namespace = "com.droidknights.app2023.feature.wearsession" +} + +dependencies { + implementation(libs.kotlinx.immutable) + + implementation(libs.androidx.compose.wear.foundation) + implementation(libs.androidx.compose.wear.material) + implementation(libs.androidx.compose.wear.navigation) +} diff --git a/feature/wear-session/src/main/AndroidManifest.xml b/feature/wear-session/src/main/AndroidManifest.xml new file mode 100644 index 00000000..a5918e68 --- /dev/null +++ b/feature/wear-session/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature/wear-session/src/main/kotlin/com/droidknights/app2023/feature/wearsession/WearSessionCard.kt b/feature/wear-session/src/main/kotlin/com/droidknights/app2023/feature/wearsession/WearSessionCard.kt new file mode 100644 index 00000000..99e629af --- /dev/null +++ b/feature/wear-session/src/main/kotlin/com/droidknights/app2023/feature/wearsession/WearSessionCard.kt @@ -0,0 +1,134 @@ +package com.droidknights.app2023.feature.wearsession + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.droidknights.app2023.core.designsystem.component.KnightsCard +import com.droidknights.app2023.core.designsystem.component.NetworkImage +import com.droidknights.app2023.core.designsystem.theme.KnightsTheme +import com.droidknights.app2023.core.model.Level +import com.droidknights.app2023.core.model.Room +import com.droidknights.app2023.core.model.Session +import com.droidknights.app2023.core.model.Speaker +import com.droidknights.app2023.core.model.Tag +import kotlinx.datetime.LocalDateTime + +@Composable +internal fun SessionCard( + session: Session, + modifier: Modifier = Modifier, + onSessionClick: (Session) -> Unit = { }, +) { + if (session.video != null) { + KnightsCard( + modifier = modifier, + onClick = { onSessionClick(session) } + ) { + SessionCardContent(session = session) + } + } else { + KnightsCard( + modifier = modifier + ) { + SessionCardContent(session = session) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun SessionCardContent( + session: Session, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp, 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + NetworkImage( + imageUrl = session.speakers.firstOrNull()?.imageUrl ?: DefaultImageUrl, + modifier = Modifier + .size(36.dp) + .clip(CircleShape), + placeholder = painterResource(id = com.droidknights.app2023.core.ui.R.drawable.placeholder_speaker), + ) + Column( + modifier = Modifier.weight(1F), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // 제목 + Text( + modifier = Modifier.basicMarquee(), + text = session.title, + style = KnightsTheme.typography.titleMediumB, + color = MaterialTheme.colorScheme.onPrimaryContainer, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + // 발표자 + session.speakers + .joinToString(",") { it.name } + .takeIf { it.isNotBlank() } + ?.let { speakerName -> + Text( + modifier = Modifier.basicMarquee(), + text = speakerName, + style = KnightsTheme.typography.bodyMediumR, + color = MaterialTheme.colorScheme.onSecondaryContainer, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } +} + +@Preview +@Composable +private fun SessionCardPreview() { + val fakeSession = Session( + id = "1", + title = "Jetpack Compose에 있는 것, 없는 것", + content = "", + speakers = listOf( + Speaker( + name = "안성용", + introduction = "안드로이드 개발자", + imageUrl = "https://picsum.photos/200", + ), + ), + level = Level.BASIC, + tags = listOf( + Tag("효율적인 코드베이스") + ), + startTime = LocalDateTime(2023, 9, 12, 16, 10, 0), + endTime = LocalDateTime(2023, 9, 12, 16, 45, 0), + room = Room.TRACK1, + video = null, + isBookmarked = true + ) + + KnightsTheme { + SessionCard(fakeSession) + } +} + +private const val DefaultImageUrl = "https://raw.githubusercontent.com/workspace/media-samples/main/img/logo.jpg" diff --git a/feature/wear-session/src/main/kotlin/com/droidknights/app2023/feature/wearsession/WearSessionScreen.kt b/feature/wear-session/src/main/kotlin/com/droidknights/app2023/feature/wearsession/WearSessionScreen.kt new file mode 100644 index 00000000..01080078 --- /dev/null +++ b/feature/wear-session/src/main/kotlin/com/droidknights/app2023/feature/wearsession/WearSessionScreen.kt @@ -0,0 +1,133 @@ +package com.droidknights.app2023.feature.wearsession + +import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Divider +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.rotary.onRotaryScrollEvent +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.wear.compose.foundation.lazy.ScalingLazyColumn +import androidx.wear.compose.foundation.lazy.ScalingLazyListScope +import androidx.wear.compose.foundation.lazy.items +import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState +import com.droidknights.app2023.core.designsystem.theme.KnightsTheme +import com.droidknights.app2023.core.model.Room +import com.droidknights.app2023.core.model.Session +import com.droidknights.app2023.core.ui.RoomText +import kotlinx.collections.immutable.PersistentList +import kotlinx.coroutines.launch + +@Composable +internal fun WearSessionScreen( + onSessionClick: (Session) -> Unit, + wearSessionViewModel: WearSessionViewModel = hiltViewModel(), +) { + val wearSessionUiState by wearSessionViewModel.uiState.collectAsStateWithLifecycle() + + WearSessionContent( + wearSessionUiState = wearSessionUiState, + modifier = Modifier.fillMaxSize(), + onSessionClick = onSessionClick, + ) +} + +@Composable +private fun WearSessionContent( + wearSessionUiState: WearSessionUiState, + onSessionClick: (Session) -> Unit, + modifier: Modifier = Modifier, +) { + when (wearSessionUiState) { + WearSessionUiState.Loading -> Box(modifier, contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + is WearSessionUiState.Sessions -> { + val focusRequester = remember { FocusRequester() } + val coroutineScope = rememberCoroutineScope() + val listState = rememberScalingLazyListState() + // https://developer.android.com/training/wearables/compose/lists + ScalingLazyColumn( + modifier = modifier + // https://developer.android.com/training/wearables/compose/rotary-input#scroll + .onRotaryScrollEvent { event -> + coroutineScope.launch { + listState.scrollBy(event.verticalScrollPixels) + } + true + } + .focusRequester(focusRequester) + .focusable(), + state = listState, + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + sessionItems( + items = wearSessionUiState.sessions, + onSessionClick = onSessionClick, + ) + } + LaunchedEffect(Unit) { focusRequester.requestFocus() } + } + } +} + +private fun ScalingLazyListScope.sessionItems( + items: PersistentList, + onSessionClick: (Session) -> Unit, +) { + items.groupBy { it.room }.entries.forEach { (room, sessions) -> + item("room-title-${room.name}") { + RoomTitle( + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), + room = room + ) + } + items(sessions, key = { session -> "session-item-$session" }) { session -> + SessionCard( + modifier = Modifier.fillMaxWidth(), + session = session, + onSessionClick = onSessionClick + ) + } + } +} + +@Composable +private fun RoomTitle( + modifier: Modifier = Modifier, + room: Room, +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally + ) { + RoomText( + room = room, + style = KnightsTheme.typography.titleLargeB, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + Spacer(modifier = Modifier.height(8.dp)) + Divider(thickness = 2.dp, color = MaterialTheme.colorScheme.onPrimaryContainer) + } +} diff --git a/feature/wear-session/src/main/kotlin/com/droidknights/app2023/feature/wearsession/WearSessionUiState.kt b/feature/wear-session/src/main/kotlin/com/droidknights/app2023/feature/wearsession/WearSessionUiState.kt new file mode 100644 index 00000000..c10575ea --- /dev/null +++ b/feature/wear-session/src/main/kotlin/com/droidknights/app2023/feature/wearsession/WearSessionUiState.kt @@ -0,0 +1,12 @@ +package com.droidknights.app2023.feature.wearsession + +import com.droidknights.app2023.core.model.Session +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.persistentListOf + +sealed interface WearSessionUiState { + object Loading : WearSessionUiState + data class Sessions( + val sessions: PersistentList = persistentListOf(), + ) : WearSessionUiState +} diff --git a/feature/wear-session/src/main/kotlin/com/droidknights/app2023/feature/wearsession/WearSessionViewModel.kt b/feature/wear-session/src/main/kotlin/com/droidknights/app2023/feature/wearsession/WearSessionViewModel.kt new file mode 100644 index 00000000..5df9c458 --- /dev/null +++ b/feature/wear-session/src/main/kotlin/com/droidknights/app2023/feature/wearsession/WearSessionViewModel.kt @@ -0,0 +1,34 @@ +package com.droidknights.app2023.feature.wearsession + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.droidknights.app2023.core.domain.usecase.GetSessionsUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject + +@HiltViewModel +class WearSessionViewModel @Inject constructor( + private val getSessionsUseCase: GetSessionsUseCase, +) : ViewModel() { + + private val _errorFlow = MutableSharedFlow() + val errorFlow: SharedFlow get() = _errorFlow + + val uiState: StateFlow = flow { emit(getSessionsUseCase().toPersistentList()) } + .map(WearSessionUiState::Sessions) + .catch { throwable -> _errorFlow.emit(throwable) } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = WearSessionUiState.Loading + ) +} diff --git a/feature/wear-session/src/main/kotlin/com/droidknights/app2023/feature/wearsession/navigation/WearSessionNavigation.kt b/feature/wear-session/src/main/kotlin/com/droidknights/app2023/feature/wearsession/navigation/WearSessionNavigation.kt new file mode 100644 index 00000000..7ae8edf3 --- /dev/null +++ b/feature/wear-session/src/main/kotlin/com/droidknights/app2023/feature/wearsession/navigation/WearSessionNavigation.kt @@ -0,0 +1,23 @@ +package com.droidknights.app2023.feature.wearsession.navigation + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.wear.compose.navigation.composable +import com.droidknights.app2023.core.model.Session +import com.droidknights.app2023.feature.wearsession.WearSessionScreen + +fun NavController.navigateWearSession() { + navigate(WearSessionRoute.route) +} + +fun NavGraphBuilder.wearSessionNavGraph( + onSessionClick: (Session) -> Unit, +) { + composable(WearSessionRoute.route) { + WearSessionScreen(onSessionClick = onSessionClick) + } +} + +object WearSessionRoute { + const val route: String = "wear-session" +} diff --git a/feature/wear-session/src/main/res/values/strings.xml b/feature/wear-session/src/main/res/values/strings.xml new file mode 100644 index 00000000..106c8f37 --- /dev/null +++ b/feature/wear-session/src/main/res/values/strings.xml @@ -0,0 +1,7 @@ + + + 세션 목록 + + 카테고리 + HH:mm 발표 + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c7d8f9ef..c625b8af 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,22 +1,26 @@ [versions] -androidGradlePlugin = "8.0.2" -androidDesugarJdkLibs = "1.2.2" -androidxCore = "1.9.0" +androidGradlePlugin = "8.1.1" +androidDesugarJdkLibs = "2.0.3" +androidxCore = "1.10.1" +androidxCoreSplashscreen = "1.0.1" androidxAppCompat = "1.6.1" androidxLifecycle = "2.6.1" -androidxComposeBom = "2023.05.01" -androidxComposeCompiler = "1.4.7" -androidxComposeNavigation = "2.6.0" -androidxComposeMaterial3 = "1.1.0" +androidxComposeBom = "2023.08.00" +androidxComposeCompiler = "1.5.3" +androidxComposeNavigation = "2.7.1" +androidxComposeMaterial3 = "1.1.1" +androidxComposeTv = "1.0.0-alpha08" +androidXComposeWear = "1.2.0" androidxActivity = "1.7.2" -hilt = "2.46.1" +androidxMedia3 = "1.1.1" +hilt = "2.47" hiltNavigationCompose = "1.0.0" okhttp = "4.11.0" retrofit = "2.9.0" retrofitKotlinxSerializationJson = "1.0.0" kotlinxSerializationJson = "1.5.1" -kotlinxDatetime = "0.2.1" +kotlinxDatetime = "0.4.0" kotlinxImmutable = "0.3.5" landscapist = "2.2.5" @@ -24,7 +28,7 @@ composeShimmer = "1.0.5" junit4 = "4.13.2" junitVintageEngine = "5.10.0" -kotlin = "1.8.21" +kotlin = "1.9.10" androidxTestExt = "1.1.4" androidxEspresso = "3.5.0" @@ -34,12 +38,13 @@ detekt = "1.23.0" mockk = "1.13.5" turbine = "1.0.0" -coroutine = "1.7.2" +coroutine = "1.7.3" androidxDatastore = "1.0.0" ossLicenses = "17.0.1" ossLicensesPlugin = "0.10.6" +play-services-wearable = "18.0.0" androidxGlance = "1.0.0-beta01" glanceExperimentalTools = "0.2.2" @@ -50,12 +55,14 @@ android-desugarJdkLibs = { group = "com.android.tools", name = "desugar_jdk_libs kotlin-gradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidxCore" } +androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "androidxCoreSplashscreen" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidxAppCompat" } androidx-lifecycle-runtimeCompose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidxLifecycle" } androidx-lifecycle-viewModelCompose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "androidxLifecycle" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidxActivity" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "androidxComposeBom" } +androidx-compose-materialIcons = { group = "androidx.compose.material", name = "material-icons-extended" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "androidxComposeMaterial3" } androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } androidx-compose-ui-test = { group = "androidx.compose.ui", name = "ui-test-junit4" } @@ -64,6 +71,17 @@ androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "u androidx-compose-ui-testManifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-compose-navigation = { group = "androidx.navigation", name = "navigation-compose", version.ref = "androidxComposeNavigation" } androidx-compose-navigation-test = { group = "androidx.navigation", name = "navigation-testing", version.ref = "androidxComposeNavigation" } +androidx-compose-tv-foundation = { group = "androidx.tv", name = "tv-foundation", version.ref = "androidxComposeTv" } +androidx-compose-tv-material = { group = "androidx.tv", name = "tv-material", version.ref = "androidxComposeTv" } +play-services-wearable = { group = "com.google.android.gms", name = "play-services-wearable", version.ref = "play-services-wearable" } +androidx-compose-wear-material = { group = "androidx.wear.compose", name = "compose-material", version.ref = "androidXComposeWear" } +androidx-compose-wear-foundation = { group = "androidx.wear.compose", name = "compose-foundation", version.ref = "androidXComposeWear" } +androidx-compose-wear-navigation = { group = "androidx.wear.compose", name = "compose-navigation", version.ref = "androidXComposeWear" } + +androidx-media3-player = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "androidxMedia3" } +androidx-media3-player-dash = { group = "androidx.media3", name = "media3-exoplayer-dash", version.ref = "androidxMedia3" } +androidx-media3-player-session = { group = "androidx.media3", name = "media3-session", version.ref = "androidxMedia3" } + hilt-core = { group = "com.google.dagger", name = "hilt-core", version.ref = "hilt" } hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" } @@ -101,6 +119,7 @@ turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutine" } coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutine" } +coroutines-guava = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-guava", version.ref = "coroutine" } coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutine" } oss-licenses = { group = "com.google.android.gms", name = "play-services-oss-licenses", version.ref = "ossLicenses" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index c895404e..bbf3ca44 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Sun Jun 04 12:42:51 KST 2023 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/settings.gradle.kts b/settings.gradle.kts index ead104c5..3f126539 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -18,12 +18,16 @@ dependencyResolutionManagement { rootProject.name = "DroidKnights2023" include( ":app", + ":app-automotive", + ":app-tv", + ":app-wear-os", ":core:designsystem", ":core:data", ":core:domain", ":core:navigation", ":core:model", + ":core:playback", ":core:ui", ":core:testing", ":core:datastore", @@ -34,6 +38,12 @@ include( ":feature:setting", ":feature:contributor", ":feature:bookmark", + ":feature:player", + ":feature:tv-main", + ":feature:tv-session", + ":feature:wear-main", + ":feature:wear-player", + ":feature:wear-session", ":widget" )