update
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,6 +119,27 @@ fun VideoDetailScreen(
|
||||
Icon(Icons.Default.PlayArrow, contentDescription = null)
|
||||
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(
|
||||
onClick = { viewModel.triggerDownload(videoId) },
|
||||
modifier = Modifier.weight(1f)
|
||||
@@ -129,4 +152,5 @@ fun VideoDetailScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,16 +60,48 @@ 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() {
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Binary file not shown.
@@ -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"])
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Binary file not shown.
@@ -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()
|
||||
|
||||
32
backend/services/stream_service.py
Normal file
32
backend/services/stream_service.py
Normal 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()
|
||||
Reference in New Issue
Block a user