This commit is contained in:
Marek
2026-04-05 15:38:01 +02:00
parent b5659069b1
commit 6e96c5ee99
13 changed files with 174 additions and 65 deletions

View File

@@ -0,0 +1,37 @@
package com.youtubeapp.data
import android.content.Context
import java.io.File
import java.io.FileOutputStream
import java.net.URL
class LocalStorageService(private val context: Context) {
private fun videosDir(): File {
val dir = File(context.filesDir, "videos")
if (!dir.exists()) dir.mkdirs()
return dir
}
private fun videoFile(videoId: Int): File = File(videosDir(), "$videoId.mp4")
fun isLocallyAvailable(videoId: Int): Boolean = videoFile(videoId).exists()
fun getLocalFile(videoId: Int): File? {
val file = videoFile(videoId)
return if (file.exists()) file else null
}
fun deleteLocalFile(videoId: Int): Boolean = videoFile(videoId).delete()
fun downloadAndSave(videoId: Int): File {
val url = "${ApiClient.BASE_URL}videos/$videoId/file"
val file = videoFile(videoId)
URL(url).openStream().use { input ->
FileOutputStream(file).use { output ->
input.copyTo(output)
}
}
return file
}
}

View File

@@ -6,6 +6,5 @@ data class Video(
val youtuber: String, val youtuber: String,
val thumbnail_url: String, val thumbnail_url: String,
val youtube_url: String, val youtube_url: String,
val is_downloaded: Boolean, val is_downloaded: Boolean
val created_at: String
) )

View File

@@ -12,6 +12,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavType import androidx.navigation.NavType
@@ -30,6 +31,8 @@ import com.youtubeapp.ui.viewmodel.VideoViewModel
fun AppNavigation() { fun AppNavigation() {
val navController = rememberNavController() val navController = rememberNavController()
val viewModel: VideoViewModel = viewModel() val viewModel: VideoViewModel = viewModel()
val context = LocalContext.current
viewModel.init(context)
val navBackStackEntry by navController.currentBackStackEntryAsState() val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route val currentRoute = navBackStackEntry?.destination?.route

View File

@@ -11,9 +11,11 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -45,16 +47,12 @@ fun VideoDetailScreen(
) { ) {
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
val video = viewModel.getVideoById(videoId) val video = viewModel.getVideoById(videoId)
val isLocal = viewModel.isLocallyAvailable(videoId)
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(state.downloadStatus) { LaunchedEffect(state.downloadStatus) {
state.downloadStatus?.let { status -> state.downloadStatus?.let { status ->
val message = when (status) { snackbarHostState.showSnackbar(status)
"download_started" -> "Download gestartet"
"already_downloaded" -> "Bereits heruntergeladen"
else -> status
}
snackbarHostState.showSnackbar(message)
viewModel.clearDownloadStatus() viewModel.clearDownloadStatus()
} }
} }
@@ -101,7 +99,11 @@ fun VideoDetailScreen(
) )
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = if (video.is_downloaded) "Auf Server heruntergeladen" else "Noch nicht heruntergeladen", text = when {
isLocal -> "Lokal gespeichert"
video.is_downloaded -> "Auf Server heruntergeladen"
else -> "Noch nicht heruntergeladen"
},
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -117,6 +119,27 @@ fun VideoDetailScreen(
Icon(Icons.Default.PlayArrow, contentDescription = null) Icon(Icons.Default.PlayArrow, contentDescription = null)
Text(" Abspielen") Text(" Abspielen")
} }
if (state.isDownloading) {
OutlinedButton(
onClick = {},
enabled = false,
modifier = Modifier.weight(1f)
) {
CircularProgressIndicator(
modifier = Modifier.height(20.dp),
strokeWidth = 2.dp
)
Text(" Download...")
}
} else if (isLocal) {
OutlinedButton(
onClick = { viewModel.deleteLocalVideo(videoId) },
modifier = Modifier.weight(1f)
) {
Icon(Icons.Default.Delete, contentDescription = null)
Text(" Loeschen")
}
} else {
OutlinedButton( OutlinedButton(
onClick = { viewModel.triggerDownload(videoId) }, onClick = { viewModel.triggerDownload(videoId) },
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
@@ -129,4 +152,5 @@ fun VideoDetailScreen(
} }
} }
} }
}
} }

View File

@@ -20,11 +20,11 @@ import com.youtubeapp.ui.viewmodel.VideoViewModel
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
fun VideoPlayerScreen(videoId: Int, viewModel: VideoViewModel) { fun VideoPlayerScreen(videoId: Int, viewModel: VideoViewModel) {
val context = LocalContext.current val context = LocalContext.current
val streamUrl = viewModel.getStreamUrl(videoId) val playbackUri = viewModel.getPlaybackUri(videoId)
val exoPlayer = remember { val exoPlayer = remember {
ExoPlayer.Builder(context).build().apply { ExoPlayer.Builder(context).build().apply {
setMediaItem(MediaItem.fromUri(streamUrl)) setMediaItem(MediaItem.fromUri(playbackUri))
prepare() prepare()
playWhenReady = true playWhenReady = true
} }

View File

@@ -1,9 +1,13 @@
package com.youtubeapp.ui.viewmodel package com.youtubeapp.ui.viewmodel
import android.content.Context
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.youtubeapp.data.LocalStorageService
import com.youtubeapp.data.Video import com.youtubeapp.data.Video
import com.youtubeapp.data.VideoRepository import com.youtubeapp.data.VideoRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -12,15 +16,23 @@ data class VideoUiState(
val allVideos: List<Video> = emptyList(), val allVideos: List<Video> = emptyList(),
val downloadedVideos: List<Video> = emptyList(), val downloadedVideos: List<Video> = emptyList(),
val isLoading: Boolean = false, val isLoading: Boolean = false,
val isDownloading: Boolean = false,
val error: String? = null, val error: String? = null,
val downloadStatus: String? = null val downloadStatus: String? = null
) )
class VideoViewModel : ViewModel() { class VideoViewModel : ViewModel() {
private val repository = VideoRepository() private val repository = VideoRepository()
private var localStorage: LocalStorageService? = null
private val _state = MutableStateFlow(VideoUiState()) private val _state = MutableStateFlow(VideoUiState())
val state: StateFlow<VideoUiState> = _state val state: StateFlow<VideoUiState> = _state
fun init(context: Context) {
if (localStorage == null) {
localStorage = LocalStorageService(context.applicationContext)
}
}
fun loadAllVideos() { fun loadAllVideos() {
viewModelScope.launch { viewModelScope.launch {
_state.value = _state.value.copy(isLoading = true, error = null) _state.value = _state.value.copy(isLoading = true, error = null)
@@ -48,16 +60,48 @@ class VideoViewModel : ViewModel() {
} }
fun triggerDownload(videoId: Int) { fun triggerDownload(videoId: Int) {
viewModelScope.launch { viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(downloadStatus = null) _state.value = _state.value.copy(isDownloading = true, downloadStatus = null)
repository.triggerDownload(videoId) try {
.onSuccess { status -> // 1. Server-Download triggern
_state.value = _state.value.copy(downloadStatus = status) val result = repository.triggerDownload(videoId)
if (result.isFailure) {
_state.value = _state.value.copy(
isDownloading = false,
downloadStatus = "Fehler: ${result.exceptionOrNull()?.message}"
)
return@launch
} }
.onFailure { e ->
_state.value = _state.value.copy(downloadStatus = "Fehler: ${e.message}") // 2. Warten bis Server-Download fertig
val status = result.getOrNull()
if (status == "download_started") {
while (true) {
delay(2000)
val videosResult = repository.getAllVideos()
val video = videosResult.getOrNull()?.find { it.id == videoId }
if (video?.is_downloaded == true) break
} }
} }
// 3. Lokal speichern
localStorage?.downloadAndSave(videoId)
_state.value = _state.value.copy(
isDownloading = false,
downloadStatus = "Lokal gespeichert"
)
} catch (e: Exception) {
_state.value = _state.value.copy(
isDownloading = false,
downloadStatus = "Fehler: ${e.message}"
)
}
}
}
fun deleteLocalVideo(videoId: Int) {
localStorage?.deleteLocalFile(videoId)
_state.value = _state.value.copy(downloadStatus = "Lokal gelöscht")
} }
fun clearDownloadStatus() { fun clearDownloadStatus() {
@@ -68,5 +112,12 @@ class VideoViewModel : ViewModel() {
return _state.value.allVideos.find { it.id == videoId } return _state.value.allVideos.find { it.id == videoId }
} }
fun getStreamUrl(videoId: Int): String = repository.getStreamUrl(videoId) fun isLocallyAvailable(videoId: Int): Boolean {
return localStorage?.isLocallyAvailable(videoId) == true
}
fun getPlaybackUri(videoId: Int): String {
val localFile = localStorage?.getLocalFile(videoId)
return localFile?.toURI()?.toString() ?: repository.getStreamUrl(videoId)
}
} }

View File

@@ -1,6 +1,4 @@
from datetime import datetime from sqlalchemy import Column, Integer, String
from sqlalchemy import Column, DateTime, Integer, String
from database import Base from database import Base
@@ -14,4 +12,3 @@ class Video(Base):
thumbnail_url = Column(String, nullable=False) thumbnail_url = Column(String, nullable=False)
youtube_url = Column(String, nullable=False) youtube_url = Column(String, nullable=False)
file_path = Column(String, nullable=True) file_path = Column(String, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)

View File

@@ -8,7 +8,8 @@ from sqlalchemy.orm import Session
from database import get_db from database import get_db
from schemas import VideoCreate, VideoResponse from schemas import VideoCreate, VideoResponse
from services import video_service from services import video_service
from services.download_service import download_video, stream_video_live from services.download_service import download_video
from services.stream_service import stream_video_live
router = APIRouter(prefix="/videos", tags=["videos"]) router = APIRouter(prefix="/videos", tags=["videos"])

View File

@@ -1,5 +1,3 @@
from datetime import datetime
from pydantic import BaseModel from pydantic import BaseModel
@@ -17,7 +15,6 @@ class VideoResponse(BaseModel):
thumbnail_url: str thumbnail_url: str
youtube_url: str youtube_url: str
is_downloaded: bool is_downloaded: bool
created_at: datetime
class Config: class Config:
from_attributes = True from_attributes = True
@@ -31,5 +28,4 @@ class VideoResponse(BaseModel):
thumbnail_url=video.thumbnail_url, thumbnail_url=video.thumbnail_url,
youtube_url=video.youtube_url, youtube_url=video.youtube_url,
is_downloaded=video.file_path is not None, is_downloaded=video.file_path is not None,
created_at=video.created_at,
) )

View File

@@ -25,34 +25,3 @@ def download_video(video_id: int, youtube_url: str):
update_file_path(db, video_id, output_path) update_file_path(db, video_id, output_path)
finally: finally:
db.close() db.close()
def stream_video_live(youtube_url: str):
result = subprocess.run(
[
"yt-dlp",
"-f", "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best",
"-g", youtube_url,
],
capture_output=True, text=True, check=True,
)
urls = result.stdout.strip().split("\n")
cmd = ["ffmpeg"]
for url in urls:
cmd.extend(["-i", url])
cmd.extend(["-c", "copy", "-movflags", "frag_keyframe+empty_moov", "-f", "mp4", "pipe:1"])
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
try:
while True:
chunk = process.stdout.read(1024 * 1024)
if not chunk:
break
yield chunk
process.wait()
except GeneratorExit:
process.kill()
finally:
if process.poll() is None:
process.kill()

View File

@@ -0,0 +1,32 @@
import subprocess
def stream_video_live(youtube_url: str):
result = subprocess.run(
[
"yt-dlp",
"-f", "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best",
"-g", youtube_url,
],
capture_output=True, text=True, check=True,
)
urls = result.stdout.strip().split("\n")
cmd = ["ffmpeg"]
for url in urls:
cmd.extend(["-i", url])
cmd.extend(["-c", "copy", "-movflags", "frag_keyframe+empty_moov", "-f", "mp4", "pipe:1"])
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
try:
while True:
chunk = process.stdout.read(1024 * 1024)
if not chunk:
break
yield chunk
process.wait()
except GeneratorExit:
process.kill()
finally:
if process.poll() is None:
process.kill()