update
This commit is contained in:
@@ -34,6 +34,23 @@ dependencies {
|
|||||||
implementation(platform("androidx.compose:compose-bom:2024.12.01"))
|
implementation(platform("androidx.compose:compose-bom:2024.12.01"))
|
||||||
implementation("androidx.compose.ui:ui")
|
implementation("androidx.compose.ui:ui")
|
||||||
implementation("androidx.compose.material3:material3")
|
implementation("androidx.compose.material3:material3")
|
||||||
|
implementation("androidx.compose.material:material-icons-extended")
|
||||||
implementation("androidx.activity:activity-compose:1.9.3")
|
implementation("androidx.activity:activity-compose:1.9.3")
|
||||||
implementation("androidx.navigation:navigation-compose:2.8.5")
|
implementation("androidx.navigation:navigation-compose:2.8.5")
|
||||||
|
|
||||||
|
// Networking
|
||||||
|
implementation("com.squareup.retrofit2:retrofit:2.11.0")
|
||||||
|
implementation("com.squareup.retrofit2:converter-gson:2.11.0")
|
||||||
|
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||||
|
|
||||||
|
// Image Loading
|
||||||
|
implementation("io.coil-kt:coil-compose:2.7.0")
|
||||||
|
|
||||||
|
// Video Player
|
||||||
|
implementation("androidx.media3:media3-exoplayer:1.5.1")
|
||||||
|
implementation("androidx.media3:media3-ui:1.5.1")
|
||||||
|
|
||||||
|
// ViewModel
|
||||||
|
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
|
||||||
|
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:label="YouTube App"
|
android:label="YouTube App"
|
||||||
|
android:usesCleartextTraffic="true"
|
||||||
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
|
|||||||
@@ -3,13 +3,16 @@ package com.youtubeapp
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.compose.material3.Text
|
import com.youtubeapp.ui.navigation.AppNavigation
|
||||||
|
import com.youtubeapp.ui.theme.YouTubeAppTheme
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContent {
|
setContent {
|
||||||
Text("YouTube App")
|
YouTubeAppTheme {
|
||||||
|
AppNavigation()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
17
app/frontend/src/main/java/com/youtubeapp/data/ApiClient.kt
Normal file
17
app/frontend/src/main/java/com/youtubeapp/data/ApiClient.kt
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package com.youtubeapp.data
|
||||||
|
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import retrofit2.converter.gson.GsonConverterFactory
|
||||||
|
|
||||||
|
object ApiClient {
|
||||||
|
// Server-IP hier anpassen
|
||||||
|
const val BASE_URL = "http://192.168.178.92:8000/"
|
||||||
|
|
||||||
|
val api: VideoApi by lazy {
|
||||||
|
Retrofit.Builder()
|
||||||
|
.baseUrl(BASE_URL)
|
||||||
|
.addConverterFactory(GsonConverterFactory.create())
|
||||||
|
.build()
|
||||||
|
.create(VideoApi::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
11
app/frontend/src/main/java/com/youtubeapp/data/Video.kt
Normal file
11
app/frontend/src/main/java/com/youtubeapp/data/Video.kt
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package com.youtubeapp.data
|
||||||
|
|
||||||
|
data class Video(
|
||||||
|
val id: Int,
|
||||||
|
val title: String,
|
||||||
|
val youtuber: String,
|
||||||
|
val thumbnail_url: String,
|
||||||
|
val youtube_url: String,
|
||||||
|
val is_downloaded: Boolean,
|
||||||
|
val created_at: String
|
||||||
|
)
|
||||||
16
app/frontend/src/main/java/com/youtubeapp/data/VideoApi.kt
Normal file
16
app/frontend/src/main/java/com/youtubeapp/data/VideoApi.kt
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
package com.youtubeapp.data
|
||||||
|
|
||||||
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.POST
|
||||||
|
import retrofit2.http.Path
|
||||||
|
|
||||||
|
interface VideoApi {
|
||||||
|
@GET("videos")
|
||||||
|
suspend fun getAllVideos(): List<Video>
|
||||||
|
|
||||||
|
@GET("videos/downloaded")
|
||||||
|
suspend fun getDownloadedVideos(): List<Video>
|
||||||
|
|
||||||
|
@POST("videos/{id}/download")
|
||||||
|
suspend fun triggerDownload(@Path("id") id: Int): Map<String, String>
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.youtubeapp.data
|
||||||
|
|
||||||
|
class VideoRepository(private val api: VideoApi = ApiClient.api) {
|
||||||
|
|
||||||
|
suspend fun getAllVideos(): Result<List<Video>> = runCatching { api.getAllVideos() }
|
||||||
|
|
||||||
|
suspend fun getDownloadedVideos(): Result<List<Video>> = runCatching { api.getDownloadedVideos() }
|
||||||
|
|
||||||
|
suspend fun triggerDownload(videoId: Int): Result<String> = runCatching {
|
||||||
|
val response = api.triggerDownload(videoId)
|
||||||
|
response["status"] ?: "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getStreamUrl(videoId: Int): String = "${ApiClient.BASE_URL}videos/$videoId/stream"
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package com.youtubeapp.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import com.youtubeapp.data.Video
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun VideoCard(video: Video, onClick: () -> Unit) {
|
||||||
|
Card(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
AsyncImage(
|
||||||
|
model = video.thumbnail_url,
|
||||||
|
contentDescription = video.title,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.aspectRatio(16f / 9f)
|
||||||
|
)
|
||||||
|
Column(modifier = Modifier.padding(8.dp)) {
|
||||||
|
Text(
|
||||||
|
text = video.title,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = video.youtuber,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package com.youtubeapp.ui.navigation
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Download
|
||||||
|
import androidx.compose.material.icons.filled.VideoLibrary
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.NavigationBar
|
||||||
|
import androidx.compose.material3.NavigationBarItem
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||||
|
import androidx.navigation.NavType
|
||||||
|
import androidx.navigation.compose.NavHost
|
||||||
|
import androidx.navigation.compose.composable
|
||||||
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import androidx.navigation.navArgument
|
||||||
|
import com.youtubeapp.ui.screens.AllVideosScreen
|
||||||
|
import com.youtubeapp.ui.screens.DownloadedScreen
|
||||||
|
import com.youtubeapp.ui.screens.VideoDetailScreen
|
||||||
|
import com.youtubeapp.ui.screens.VideoPlayerScreen
|
||||||
|
import com.youtubeapp.ui.viewmodel.VideoViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AppNavigation() {
|
||||||
|
val navController = rememberNavController()
|
||||||
|
val viewModel: VideoViewModel = viewModel()
|
||||||
|
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||||
|
val currentRoute = navBackStackEntry?.destination?.route
|
||||||
|
|
||||||
|
val showBottomBar = currentRoute in listOf(Route.AllVideos.route, Route.Downloaded.route)
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
bottomBar = {
|
||||||
|
if (showBottomBar) {
|
||||||
|
NavigationBar {
|
||||||
|
NavigationBarItem(
|
||||||
|
selected = currentRoute == Route.AllVideos.route,
|
||||||
|
onClick = {
|
||||||
|
navController.navigate(Route.AllVideos.route) {
|
||||||
|
popUpTo(navController.graph.findStartDestination().id) { saveState = true }
|
||||||
|
launchSingleTop = true
|
||||||
|
restoreState = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon = { Icon(Icons.Default.VideoLibrary, contentDescription = null) },
|
||||||
|
label = { Text("Alle Videos") }
|
||||||
|
)
|
||||||
|
NavigationBarItem(
|
||||||
|
selected = currentRoute == Route.Downloaded.route,
|
||||||
|
onClick = {
|
||||||
|
navController.navigate(Route.Downloaded.route) {
|
||||||
|
popUpTo(navController.graph.findStartDestination().id) { saveState = true }
|
||||||
|
launchSingleTop = true
|
||||||
|
restoreState = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon = { Icon(Icons.Default.Download, contentDescription = null) },
|
||||||
|
label = { Text("Heruntergeladen") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { innerPadding ->
|
||||||
|
NavHost(
|
||||||
|
navController = navController,
|
||||||
|
startDestination = Route.AllVideos.route,
|
||||||
|
modifier = Modifier.padding(innerPadding)
|
||||||
|
) {
|
||||||
|
composable(Route.AllVideos.route) {
|
||||||
|
AllVideosScreen(viewModel = viewModel, onVideoClick = { videoId ->
|
||||||
|
navController.navigate(Route.VideoDetail.createRoute(videoId))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
composable(Route.Downloaded.route) {
|
||||||
|
DownloadedScreen(viewModel = viewModel, onVideoClick = { videoId ->
|
||||||
|
navController.navigate(Route.VideoDetail.createRoute(videoId))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
composable(
|
||||||
|
route = Route.VideoDetail.route,
|
||||||
|
arguments = listOf(navArgument("videoId") { type = NavType.IntType })
|
||||||
|
) { backStackEntry ->
|
||||||
|
val videoId = backStackEntry.arguments?.getInt("videoId") ?: return@composable
|
||||||
|
VideoDetailScreen(
|
||||||
|
videoId = videoId,
|
||||||
|
viewModel = viewModel,
|
||||||
|
onPlayClick = { navController.navigate(Route.VideoPlayer.createRoute(videoId)) },
|
||||||
|
onBackClick = { navController.popBackStack() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(
|
||||||
|
route = Route.VideoPlayer.route,
|
||||||
|
arguments = listOf(navArgument("videoId") { type = NavType.IntType })
|
||||||
|
) { backStackEntry ->
|
||||||
|
val videoId = backStackEntry.arguments?.getInt("videoId") ?: return@composable
|
||||||
|
VideoPlayerScreen(videoId = videoId, viewModel = viewModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.youtubeapp.ui.navigation
|
||||||
|
|
||||||
|
sealed class Route(val route: String) {
|
||||||
|
object AllVideos : Route("all_videos")
|
||||||
|
object Downloaded : Route("downloaded")
|
||||||
|
object VideoDetail : Route("video_detail/{videoId}") {
|
||||||
|
fun createRoute(videoId: Int) = "video_detail/$videoId"
|
||||||
|
}
|
||||||
|
object VideoPlayer : Route("video_player/{videoId}") {
|
||||||
|
fun createRoute(videoId: Int) = "video_player/$videoId"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package com.youtubeapp.ui.screens
|
||||||
|
|
||||||
|
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.fillMaxSize
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.youtubeapp.ui.components.VideoCard
|
||||||
|
import com.youtubeapp.ui.viewmodel.VideoViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AllVideosScreen(viewModel: VideoViewModel, onVideoClick: (Int) -> Unit) {
|
||||||
|
val state by viewModel.state.collectAsState()
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.loadAllVideos()
|
||||||
|
}
|
||||||
|
|
||||||
|
when {
|
||||||
|
state.isLoading && state.allVideos.isEmpty() -> {
|
||||||
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.error != null && state.allVideos.isEmpty() -> {
|
||||||
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text("Fehler: ${state.error}")
|
||||||
|
Button(onClick = { viewModel.loadAllVideos() }) {
|
||||||
|
Text("Erneut versuchen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
LazyVerticalGrid(
|
||||||
|
columns = GridCells.Fixed(2),
|
||||||
|
contentPadding = PaddingValues(8.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
) {
|
||||||
|
items(state.allVideos) { video ->
|
||||||
|
VideoCard(video = video, onClick = { onVideoClick(video.id) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package com.youtubeapp.ui.screens
|
||||||
|
|
||||||
|
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.fillMaxSize
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.youtubeapp.ui.components.VideoCard
|
||||||
|
import com.youtubeapp.ui.viewmodel.VideoViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DownloadedScreen(viewModel: VideoViewModel, onVideoClick: (Int) -> Unit) {
|
||||||
|
val state by viewModel.state.collectAsState()
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.loadDownloadedVideos()
|
||||||
|
}
|
||||||
|
|
||||||
|
when {
|
||||||
|
state.isLoading && state.downloadedVideos.isEmpty() -> {
|
||||||
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.error != null && state.downloadedVideos.isEmpty() -> {
|
||||||
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text("Fehler: ${state.error}")
|
||||||
|
Button(onClick = { viewModel.loadDownloadedVideos() }) {
|
||||||
|
Text("Erneut versuchen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.downloadedVideos.isEmpty() -> {
|
||||||
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Text("Keine heruntergeladenen Videos")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
LazyVerticalGrid(
|
||||||
|
columns = GridCells.Fixed(2),
|
||||||
|
contentPadding = PaddingValues(8.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
) {
|
||||||
|
items(state.downloadedVideos) { video ->
|
||||||
|
VideoCard(video = video, onClick = { onVideoClick(video.id) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package com.youtubeapp.ui.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
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.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Download
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import com.youtubeapp.ui.viewmodel.VideoViewModel
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun VideoDetailScreen(
|
||||||
|
videoId: Int,
|
||||||
|
viewModel: VideoViewModel,
|
||||||
|
onPlayClick: () -> Unit,
|
||||||
|
onBackClick: () -> Unit
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsState()
|
||||||
|
val video = viewModel.getVideoById(videoId)
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
|
LaunchedEffect(state.downloadStatus) {
|
||||||
|
state.downloadStatus?.let { status ->
|
||||||
|
val message = when (status) {
|
||||||
|
"download_started" -> "Download gestartet"
|
||||||
|
"already_downloaded" -> "Bereits heruntergeladen"
|
||||||
|
else -> status
|
||||||
|
}
|
||||||
|
snackbarHostState.showSnackbar(message)
|
||||||
|
viewModel.clearDownloadStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(video?.title ?: "Video") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBackClick) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurueck")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) }
|
||||||
|
) { innerPadding ->
|
||||||
|
if (video == null) {
|
||||||
|
Text("Video nicht gefunden", modifier = Modifier.padding(innerPadding).padding(16.dp))
|
||||||
|
} else {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding)
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = video.thumbnail_url,
|
||||||
|
contentDescription = video.title,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.aspectRatio(16f / 9f)
|
||||||
|
)
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Text(
|
||||||
|
text = video.title,
|
||||||
|
style = MaterialTheme.typography.headlineSmall
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = video.youtuber,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = if (video.is_downloaded) "Auf Server heruntergeladen" else "Noch nicht heruntergeladen",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
onClick = onPlayClick,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.PlayArrow, contentDescription = null)
|
||||||
|
Text(" Abspielen")
|
||||||
|
}
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { viewModel.triggerDownload(videoId) },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Download, contentDescription = null)
|
||||||
|
Text(" Download")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package com.youtubeapp.ui.screens
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import androidx.core.view.WindowCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import androidx.core.view.WindowInsetsControllerCompat
|
||||||
|
import androidx.media3.common.MediaItem
|
||||||
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
import androidx.media3.ui.PlayerView
|
||||||
|
import com.youtubeapp.ui.viewmodel.VideoViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||||
|
fun VideoPlayerScreen(videoId: Int, viewModel: VideoViewModel) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val streamUrl = viewModel.getStreamUrl(videoId)
|
||||||
|
|
||||||
|
val exoPlayer = remember {
|
||||||
|
ExoPlayer.Builder(context).build().apply {
|
||||||
|
setMediaItem(MediaItem.fromUri(streamUrl))
|
||||||
|
prepare()
|
||||||
|
playWhenReady = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
val activity = context as? Activity
|
||||||
|
val window = activity?.window
|
||||||
|
val controller = window?.let {
|
||||||
|
WindowCompat.getInsetsController(it, it.decorView)
|
||||||
|
}
|
||||||
|
controller?.let {
|
||||||
|
it.hide(WindowInsetsCompat.Type.systemBars())
|
||||||
|
it.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||||
|
}
|
||||||
|
|
||||||
|
onDispose {
|
||||||
|
exoPlayer.release()
|
||||||
|
controller?.show(WindowInsetsCompat.Type.systemBars())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AndroidView(
|
||||||
|
factory = { ctx ->
|
||||||
|
PlayerView(ctx).apply {
|
||||||
|
player = exoPlayer
|
||||||
|
useController = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
}
|
||||||
18
app/frontend/src/main/java/com/youtubeapp/ui/theme/Theme.kt
Normal file
18
app/frontend/src/main/java/com/youtubeapp/ui/theme/Theme.kt
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package com.youtubeapp.ui.theme
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicDarkColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun YouTubeAppTheme(content: @Composable () -> Unit) {
|
||||||
|
val colorScheme = if (Build.VERSION.SDK_INT >= 31) {
|
||||||
|
dynamicDarkColorScheme(LocalContext.current)
|
||||||
|
} else {
|
||||||
|
darkColorScheme()
|
||||||
|
}
|
||||||
|
MaterialTheme(colorScheme = colorScheme, content = content)
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package com.youtubeapp.ui.viewmodel
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.youtubeapp.data.Video
|
||||||
|
import com.youtubeapp.data.VideoRepository
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
data class VideoUiState(
|
||||||
|
val allVideos: List<Video> = emptyList(),
|
||||||
|
val downloadedVideos: List<Video> = emptyList(),
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val error: String? = null,
|
||||||
|
val downloadStatus: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
class VideoViewModel : ViewModel() {
|
||||||
|
private val repository = VideoRepository()
|
||||||
|
private val _state = MutableStateFlow(VideoUiState())
|
||||||
|
val state: StateFlow<VideoUiState> = _state
|
||||||
|
|
||||||
|
fun loadAllVideos() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(isLoading = true, error = null)
|
||||||
|
repository.getAllVideos()
|
||||||
|
.onSuccess { videos ->
|
||||||
|
_state.value = _state.value.copy(allVideos = videos, isLoading = false)
|
||||||
|
}
|
||||||
|
.onFailure { e ->
|
||||||
|
_state.value = _state.value.copy(error = e.message, isLoading = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadDownloadedVideos() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(isLoading = true, error = null)
|
||||||
|
repository.getDownloadedVideos()
|
||||||
|
.onSuccess { videos ->
|
||||||
|
_state.value = _state.value.copy(downloadedVideos = videos, isLoading = false)
|
||||||
|
}
|
||||||
|
.onFailure { e ->
|
||||||
|
_state.value = _state.value.copy(error = e.message, isLoading = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun triggerDownload(videoId: Int) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(downloadStatus = null)
|
||||||
|
repository.triggerDownload(videoId)
|
||||||
|
.onSuccess { status ->
|
||||||
|
_state.value = _state.value.copy(downloadStatus = status)
|
||||||
|
}
|
||||||
|
.onFailure { e ->
|
||||||
|
_state.value = _state.value.copy(downloadStatus = "Fehler: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearDownloadStatus() {
|
||||||
|
_state.value = _state.value.copy(downloadStatus = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getVideoById(videoId: Int): Video? {
|
||||||
|
return _state.value.allVideos.find { it.id == videoId }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getStreamUrl(videoId: Int): String = repository.getStreamUrl(videoId)
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
kotlin.code.style=official
|
kotlin.code.style=official
|
||||||
org.gradle.jvmargs=-Xmx2048m
|
org.gradle.jvmargs=-Xmx2048m
|
||||||
|
org.gradle.java.home=/usr/lib/jvm/java-17-openjdk-amd64
|
||||||
|
|||||||
Reference in New Issue
Block a user