This commit is contained in:
Marek
2026-04-05 10:46:07 +02:00
parent f250b2baf9
commit 6662b34290
17 changed files with 661 additions and 2 deletions

View File

@@ -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")
} }

View File

@@ -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

View File

@@ -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()
}
} }
} }
} }

View 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)
}
}

View 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
)

View 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>
}

View File

@@ -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"
}

View File

@@ -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
)
}
}
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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"
}
}

View File

@@ -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) })
}
}
}
}
}

View File

@@ -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) })
}
}
}
}
}

View File

@@ -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")
}
}
}
}
}
}
}

View File

@@ -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()
)
}

View 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)
}

View File

@@ -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)
}

View File

@@ -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