This commit is contained in:
Marek Lenczewski
2026-04-06 10:42:29 +02:00
parent b6635a107d
commit 8ecef00d0a
14 changed files with 128 additions and 61 deletions

File diff suppressed because one or more lines are too long

View File

@@ -5,7 +5,7 @@ import retrofit2.converter.gson.GsonConverterFactory
object ApiClient { object ApiClient {
// Server-IP hier anpassen // Server-IP hier anpassen
const val BASE_URL = "http://192.168.178.92:8000/" const val BASE_URL = "http://marha.local:8000/"
val api: VideoApi by lazy { val api: VideoApi by lazy {
Retrofit.Builder() Retrofit.Builder()

View File

@@ -157,7 +157,7 @@ fun AppNavigation() {
arguments = listOf(navArgument("videoId") { type = NavType.IntType }) arguments = listOf(navArgument("videoId") { type = NavType.IntType })
) { backStackEntry -> ) { backStackEntry ->
val videoId = backStackEntry.arguments?.getInt("videoId") ?: return@composable val videoId = backStackEntry.arguments?.getInt("videoId") ?: return@composable
VideoPlayerScreen(videoId = videoId, viewModel = viewModel) VideoPlayerScreen(videoId = videoId, viewModel = viewModel, onBack = { navController.popBackStack() })
} }
} }
} }

View File

@@ -2,12 +2,21 @@ package com.youtubeapp.ui.screens
import android.app.Activity import android.app.Activity
import android.view.LayoutInflater import android.view.LayoutInflater
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
@@ -20,7 +29,7 @@ import com.youtubeapp.ui.viewmodel.VideoViewModel
@Composable @Composable
@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, onBack: () -> Unit) {
val context = LocalContext.current val context = LocalContext.current
val playbackUri = viewModel.getPlaybackUri(videoId) val playbackUri = viewModel.getPlaybackUri(videoId)
@@ -49,6 +58,7 @@ fun VideoPlayerScreen(videoId: Int, viewModel: VideoViewModel) {
} }
} }
Box(modifier = Modifier.fillMaxSize()) {
AndroidView( AndroidView(
factory = { ctx -> factory = { ctx ->
val view = LayoutInflater.from(ctx).inflate(R.layout.player_view, null) as PlayerView val view = LayoutInflater.from(ctx).inflate(R.layout.player_view, null) as PlayerView
@@ -60,4 +70,17 @@ fun VideoPlayerScreen(videoId: Int, viewModel: VideoViewModel) {
}, },
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
IconButton(
onClick = onBack,
modifier = Modifier
.align(Alignment.TopStart)
.padding(16.dp)
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Zurück",
tint = Color.White
)
}
}
} }

View File

@@ -195,6 +195,10 @@ class VideoViewModel : ViewModel() {
fun getPlaybackUri(videoId: Int): String { fun getPlaybackUri(videoId: Int): String {
val localFile = localStorage?.getLocalFile(videoId) val localFile = localStorage?.getLocalFile(videoId)
return localFile?.toURI()?.toString() ?: repository.getStreamUrl(videoId) return if (localFile != null) {
android.net.Uri.fromFile(localFile).toString()
} else {
repository.getStreamUrl(videoId)
}
} }
} }

View File

@@ -104,7 +104,8 @@ def download_file(video_id: int, db: Session = Depends(get_db)):
path = Path(video.file_path) path = Path(video.file_path)
if not path.exists(): if not path.exists():
raise HTTPException(status_code=404, detail="Videodatei nicht gefunden") video_service.update_file_path(db, video_id, None)
raise HTTPException(status_code=404, detail="Video noch nicht heruntergeladen")
return FileResponse(path, media_type="video/mp4", filename=f"{video.title}.mp4") return FileResponse(path, media_type="video/mp4", filename=f"{video.title}.mp4")

View File

@@ -1,53 +1,91 @@
import subprocess import subprocess
import time
from pathlib import Path from pathlib import Path
VIDEOS_DIR = "/videos" VIDEOS_DIR = "/videos"
CHUNK_SIZE = 64 * 1024
def _get_stream_urls(youtube_url: str):
result = subprocess.run(
[
"yt-dlp",
"-f", "bestvideo[ext=mp4][vcodec^=avc]+bestaudio[ext=m4a]/best[ext=mp4]",
"--print", "urls",
youtube_url,
],
capture_output=True, text=True, timeout=30,
)
if result.returncode != 0:
return None, None
lines = result.stdout.strip().splitlines()
if len(lines) >= 2:
return lines[0], lines[1]
elif len(lines) == 1:
return lines[0], None
return None, None
def stream_video_live(video_id: int, youtube_url: str): def stream_video_live(video_id: int, youtube_url: str):
output_path = f"{VIDEOS_DIR}/{video_id}.mp4" output_path = f"{VIDEOS_DIR}/{video_id}.mp4"
path = Path(output_path)
video_url, audio_url = _get_stream_urls(youtube_url)
if not video_url:
return
if audio_url:
cmd = [
"ffmpeg",
"-reconnect", "1",
"-reconnect_streamed", "1",
"-reconnect_delay_max", "5",
"-i", video_url,
"-reconnect", "1",
"-reconnect_streamed", "1",
"-reconnect_delay_max", "5",
"-i", audio_url,
"-c:v", "copy",
"-c:a", "aac",
"-movflags", "frag_keyframe+empty_moov+default_base_moof",
"-f", "mp4",
"pipe:1",
]
else:
cmd = [
"ffmpeg",
"-reconnect", "1",
"-reconnect_streamed", "1",
"-reconnect_delay_max", "5",
"-i", video_url,
"-c", "copy",
"-movflags", "frag_keyframe+empty_moov+default_base_moof",
"-f", "mp4",
"pipe:1",
]
process = subprocess.Popen( process = subprocess.Popen(
[ cmd,
"yt-dlp", stdout=subprocess.PIPE,
"-f", "best[ext=mp4][vcodec^=avc]/best[ext=mp4]",
"-o", output_path,
youtube_url,
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
) )
# Warte bis Datei existiert und mindestens 1MB hat try:
while process.poll() is None: with open(output_path, "wb") as f:
if path.exists() and path.stat().st_size >= 1024 * 1024:
break
time.sleep(0.5)
if not path.exists():
process.wait()
return
# Streame aus der wachsenden Datei
pos = 0
stall_count = 0
with open(output_path, "rb") as f:
while True: while True:
chunk = f.read(1024 * 1024) chunk = process.stdout.read(CHUNK_SIZE)
if chunk: if not chunk:
pos += len(chunk) break
stall_count = 0 f.write(chunk)
yield chunk yield chunk
else: except GeneratorExit:
if process.poll() is not None: pass
# Download fertig — restliche Bytes lesen finally:
remaining = f.read() if process.poll() is None:
if remaining: process.kill()
yield remaining process.wait()
break if process.stdout:
stall_count += 1 process.stdout.close()
if stall_count > 60: # 30 Sekunden ohne neue Daten
break path = Path(output_path)
time.sleep(0.5) if process.returncode != 0 and path.exists():
path.unlink()

View File

@@ -8,7 +8,8 @@ def create_video(db: Session, video_data: VideoCreate) -> Video:
profile_id = video_data.profile_id profile_id = video_data.profile_id
data = video_data.model_dump(exclude={"profile_id"}) data = video_data.model_dump(exclude={"profile_id"})
video = Video(**data) video = Video(**data)
if profile_id: if not profile_id:
profile_id = 1
profile = db.query(Profile).filter(Profile.id == profile_id).first() profile = db.query(Profile).filter(Profile.id == profile_id).first()
if profile: if profile:
video.profiles.append(profile) video.profiles.append(profile)

View File

@@ -1,4 +1,4 @@
const SERVER_URL = "http://localhost:8000/videos"; const SERVER_URL = "http://marha.local:8000/videos";
browser.runtime.onMessage.addListener((videos) => { browser.runtime.onMessage.addListener((videos) => {
fetch(SERVER_URL, { fetch(SERVER_URL, {