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 {
// 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 {
Retrofit.Builder()

View File

@@ -157,7 +157,7 @@ fun AppNavigation() {
arguments = listOf(navArgument("videoId") { type = NavType.IntType })
) { backStackEntry ->
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.view.LayoutInflater
import androidx.compose.foundation.layout.Box
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.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
@@ -20,7 +29,7 @@ import com.youtubeapp.ui.viewmodel.VideoViewModel
@Composable
@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 playbackUri = viewModel.getPlaybackUri(videoId)
@@ -49,15 +58,29 @@ fun VideoPlayerScreen(videoId: Int, viewModel: VideoViewModel) {
}
}
AndroidView(
factory = { ctx ->
val view = LayoutInflater.from(ctx).inflate(R.layout.player_view, null) as PlayerView
view.player = exoPlayer
view
},
update = { view ->
view.player = exoPlayer
},
modifier = Modifier.fillMaxSize()
)
Box(modifier = Modifier.fillMaxSize()) {
AndroidView(
factory = { ctx ->
val view = LayoutInflater.from(ctx).inflate(R.layout.player_view, null) as PlayerView
view.player = exoPlayer
view
},
update = { view ->
view.player = exoPlayer
},
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 {
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)
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")

View File

@@ -1,53 +1,91 @@
import subprocess
import time
from pathlib import Path
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):
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(
[
"yt-dlp",
"-f", "best[ext=mp4][vcodec^=avc]/best[ext=mp4]",
"-o", output_path,
youtube_url,
],
stdout=subprocess.DEVNULL,
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
)
# Warte bis Datei existiert und mindestens 1MB hat
while process.poll() is None:
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:
chunk = f.read(1024 * 1024)
if chunk:
pos += len(chunk)
stall_count = 0
try:
with open(output_path, "wb") as f:
while True:
chunk = process.stdout.read(CHUNK_SIZE)
if not chunk:
break
f.write(chunk)
yield chunk
else:
if process.poll() is not None:
# Download fertig — restliche Bytes lesen
remaining = f.read()
if remaining:
yield remaining
break
stall_count += 1
if stall_count > 60: # 30 Sekunden ohne neue Daten
break
time.sleep(0.5)
except GeneratorExit:
pass
finally:
if process.poll() is None:
process.kill()
process.wait()
if process.stdout:
process.stdout.close()
path = Path(output_path)
if process.returncode != 0 and path.exists():
path.unlink()

View File

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

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) => {
fetch(SERVER_URL, {