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 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
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|||||||
Binary file not shown.
@@ -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"])
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
|
||||||
)
|
)
|
||||||
|
|||||||
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)
|
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()
|
|
||||||
|
|||||||
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