update
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -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()
|
||||||
|
|||||||
@@ -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() })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -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")
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
Reference in New Issue
Block a user