diff --git a/README.md b/README.md
index 1cd6ad0f..20be6c5b 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,54 @@
+
+
+# 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에서 생성.
+
+
+
+### Desktop Head Unit Emulator 만들기 (Android Auto)
+
+공식 [가이드](https://developer.android.com/training/cars/testing/dhu)를 따라 `Desktop Head Unit Emulator(DHU)`를 설치. 모바일 에뮬레이터 또는 실기기가 연결된 상태에서 DHU 실행하면 Android Auto 활성화
+
+
+
+### Run Configurations
+
+실행 해보고 싶은 것과 Emulator를 고른 뒤 `Run`
+
+
+
+- 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 원문
# 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"
)