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 thumbnail_url: String,
val youtube_url: String,
val is_downloaded: Boolean,
val created_at: String
val is_downloaded: Boolean
)

View File

@@ -12,6 +12,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavType
@@ -30,6 +31,8 @@ import com.youtubeapp.ui.viewmodel.VideoViewModel
fun AppNavigation() {
val navController = rememberNavController()
val viewModel: VideoViewModel = viewModel()
val context = LocalContext.current
viewModel.init(context)
val navBackStackEntry by navController.currentBackStackEntryAsState()
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.material.icons.Icons
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.PlayArrow
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -45,16 +47,12 @@ fun VideoDetailScreen(
) {
val state by viewModel.state.collectAsState()
val video = viewModel.getVideoById(videoId)
val isLocal = viewModel.isLocallyAvailable(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)
snackbarHostState.showSnackbar(status)
viewModel.clearDownloadStatus()
}
}
@@ -101,7 +99,11 @@ fun VideoDetailScreen(
)
Spacer(modifier = Modifier.height(4.dp))
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,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -117,12 +119,34 @@ fun VideoDetailScreen(
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")
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(
onClick = { viewModel.triggerDownload(videoId) },
modifier = Modifier.weight(1f)
) {
Icon(Icons.Default.Download, contentDescription = null)
Text(" Download")
}
}
}
}

View File

@@ -20,11 +20,11 @@ import com.youtubeapp.ui.viewmodel.VideoViewModel
@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 playbackUri = viewModel.getPlaybackUri(videoId)
val exoPlayer = remember {
ExoPlayer.Builder(context).build().apply {
setMediaItem(MediaItem.fromUri(streamUrl))
setMediaItem(MediaItem.fromUri(playbackUri))
prepare()
playWhenReady = true
}

View File

@@ -1,9 +1,13 @@
package com.youtubeapp.ui.viewmodel
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.youtubeapp.data.LocalStorageService
import com.youtubeapp.data.Video
import com.youtubeapp.data.VideoRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
@@ -12,15 +16,23 @@ data class VideoUiState(
val allVideos: List<Video> = emptyList(),
val downloadedVideos: List<Video> = emptyList(),
val isLoading: Boolean = false,
val isDownloading: Boolean = false,
val error: String? = null,
val downloadStatus: String? = null
)
class VideoViewModel : ViewModel() {
private val repository = VideoRepository()
private var localStorage: LocalStorageService? = null
private val _state = MutableStateFlow(VideoUiState())
val state: StateFlow<VideoUiState> = _state
fun init(context: Context) {
if (localStorage == null) {
localStorage = LocalStorageService(context.applicationContext)
}
}
fun loadAllVideos() {
viewModelScope.launch {
_state.value = _state.value.copy(isLoading = true, error = null)
@@ -48,18 +60,50 @@ class VideoViewModel : ViewModel() {
}
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)
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(isDownloading = true, downloadStatus = null)
try {
// 1. Server-Download triggern
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() {
_state.value = _state.value.copy(downloadStatus = null)
}
@@ -68,5 +112,12 @@ class VideoViewModel : ViewModel() {
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, DateTime, Integer, String
from sqlalchemy import Column, Integer, String
from database import Base
@@ -14,4 +12,3 @@ class Video(Base):
thumbnail_url = Column(String, nullable=False)
youtube_url = Column(String, nullable=False)
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 schemas import VideoCreate, VideoResponse
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"])

View File

@@ -1,5 +1,3 @@
from datetime import datetime
from pydantic import BaseModel
@@ -17,7 +15,6 @@ class VideoResponse(BaseModel):
thumbnail_url: str
youtube_url: str
is_downloaded: bool
created_at: datetime
class Config:
from_attributes = True
@@ -31,5 +28,4 @@ class VideoResponse(BaseModel):
thumbnail_url=video.thumbnail_url,
youtube_url=video.youtube_url,
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)
finally:
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()