From 3c1bdfb2647c221890a78c153e716be9ba244197 Mon Sep 17 00:00:00 2001 From: Merlin Scheurer Date: Mon, 20 Apr 2026 13:02:33 +0200 Subject: [PATCH] Add audio only mode --- backend/video/urls.py | 5 + backend/video/views.py | 91 +++++++++ frontend/public/img/icon-audio.svg | 15 ++ frontend/src/components/VideoPlayer.tsx | 250 +++++++++++++++++------- frontend/src/style.css | 95 ++++++++- 5 files changed, 379 insertions(+), 77 deletions(-) create mode 100644 frontend/public/img/icon-audio.svg diff --git a/backend/video/urls.py b/backend/video/urls.py index f520fb0b..ec0155cd 100644 --- a/backend/video/urls.py +++ b/backend/video/urls.py @@ -30,4 +30,9 @@ urlpatterns = [ views.VideoSimilarView.as_view(), name="api-video-similar", ), + path( + "/stream-mp3/", + views.VideoStreamMp3View.as_view(), + name="api-video-stream-mp3", + ), ] diff --git a/backend/video/views.py b/backend/video/views.py index 4bb95d87..d7235dc5 100644 --- a/backend/video/views.py +++ b/backend/video/views.py @@ -1,10 +1,15 @@ """all API views for video endpoints""" +import os +import subprocess +import urllib.parse + from common.serializers import ErrorResponseSerializer from common.src.helper import calc_is_watched from common.src.ta_redis import RedisArchivist from common.src.watched import WatchState from common.views_base import AdminWriteOnly, ApiBaseView +from django.http import StreamingHttpResponse from drf_spectacular.utils import OpenApiResponse, extend_schema from playlist.src.index import YoutubePlaylist from rest_framework.response import Response @@ -287,3 +292,89 @@ class VideoSimilarView(ApiBaseView): self.get_document_list(request, pagination=False) serializer = VideoSerializer(self.response["data"], many=True) return Response(serializer.data) + + +class VideoStreamMp3View(ApiBaseView): + """resolves to /api/video//stream-mp3/ + GET: stream the audio of a video as an MP3 using ffmpeg + """ + + search_base = "ta_video/_doc/" + + CHUNK_SIZE = 8192 + + @extend_schema( + responses={ + 200: OpenApiResponse(description="MP3 audio stream"), + 404: OpenApiResponse( + ErrorResponseSerializer(), description="video not found" + ), + } + ) + def get(self, request, video_id): + # pylint: disable=unused-argument + """stream video audio as mp3""" + self.get_document(video_id) + if self.status_code == 404: + error = ErrorResponseSerializer({"error": "video not found"}) + return Response(error.data, status=404) + + media_url = self.response.get("media_url") + if not media_url: + error = ErrorResponseSerializer({"error": "media file not found"}) + return Response(error.data, status=404) + + file_path = urllib.parse.unquote(media_url) + + if not os.path.exists(file_path): + error = ErrorResponseSerializer({"error": "media file not found"}) + return Response(error.data, status=404) + + title = self.response.get("title", video_id) + + ffmpeg_cmd = [ + "ffmpeg", + "-i", + file_path, + "-vn", + "-acodec", + "libmp3lame", + "-ab", + "192k", + "-f", + "mp3", + "pipe:1", + ] + + process = subprocess.Popen( + ffmpeg_cmd, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + ) + + def stream_chunks(): + try: + while True: + chunk = process.stdout.read(self.CHUNK_SIZE) + if not chunk: + break + yield chunk + finally: + process.stdout.close() + process.wait() + + safe_title = ( + "".join(c for c in title if c.isalnum() or c in " _-").strip() + or video_id + ) + + response = StreamingHttpResponse( + stream_chunks(), + content_type="audio/mpeg", + ) + response["Content-Disposition"] = ( + f'inline; filename="{safe_title}.mp3"' + ) + response["X-Accel-Buffering"] = "no" + + return response diff --git a/frontend/public/img/icon-audio.svg b/frontend/public/img/icon-audio.svg new file mode 100644 index 00000000..aef40211 --- /dev/null +++ b/frontend/public/img/icon-audio.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + diff --git a/frontend/src/components/VideoPlayer.tsx b/frontend/src/components/VideoPlayer.tsx index 789e56ee..8796ac83 100644 --- a/frontend/src/components/VideoPlayer.tsx +++ b/frontend/src/components/VideoPlayer.tsx @@ -18,6 +18,7 @@ import { VideoResponseType } from '../api/loader/loadVideoById'; const VIDEO_PLAYBACK_SPEEDS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.25, 2.5, 2.75, 3]; type VideoTag = SyntheticEvent; +type AudioTag = SyntheticEvent; export type SkippedSegmentType = { from: number; @@ -128,6 +129,17 @@ const VideoPlayer = ({ setSeekToTimestamp, }: VideoPlayerProps) => { const videoRef = useRef(null); + const audioRef = useRef(null); + const [audioMode, setAudioMode] = useState(localStorage.getItem('playerAudioMode') === 'true'); + + // Returns the currently active media element regardless of mode + const mediaRef = (): HTMLVideoElement | HTMLAudioElement | null => + audioMode ? audioRef.current : videoRef.current; + + const toggleAudioMode = (next: boolean) => { + localStorage.setItem('playerAudioMode', String(next)); + setAudioMode(next); + }; useEffect(() => { if (seekToTimestamp === undefined || !videoRef.current) { @@ -196,19 +208,21 @@ const VideoPlayer = ({ }); useKeyPress('p', () => { - if (videoRef.current?.paused) { - videoRef.current.play(); + const media = mediaRef(); + if (media?.paused) { + media.play(); } else { - videoRef.current?.pause(); + media?.pause(); } }); useKeyPress('>', () => { const newSpeed = playbackSpeedIndex + 1; + const media = mediaRef(); - if (videoRef.current && VIDEO_PLAYBACK_SPEEDS[newSpeed]) { + if (media && VIDEO_PLAYBACK_SPEEDS[newSpeed]) { const speed = VIDEO_PLAYBACK_SPEEDS[newSpeed]; - videoRef.current.playbackRate = speed; + media.playbackRate = speed; setPlaybackSpeedIndex(newSpeed); infoDialog(`${speed}x`); @@ -217,10 +231,11 @@ const VideoPlayer = ({ useKeyPress('<', () => { const newSpeedIndex = playbackSpeedIndex - 1; + const media = mediaRef(); - if (videoRef.current && VIDEO_PLAYBACK_SPEEDS[newSpeedIndex]) { + if (media && VIDEO_PLAYBACK_SPEEDS[newSpeedIndex]) { const speed = VIDEO_PLAYBACK_SPEEDS[newSpeedIndex]; - videoRef.current.playbackRate = speed; + media.playbackRate = speed; setPlaybackSpeedIndex(newSpeedIndex); infoDialog(`${speed}x`); @@ -229,10 +244,11 @@ const VideoPlayer = ({ useKeyPress('=', () => { const newSpeedIndex = 3; + const media = mediaRef(); - if (videoRef.current && VIDEO_PLAYBACK_SPEEDS[newSpeedIndex]) { + if (media && VIDEO_PLAYBACK_SPEEDS[newSpeedIndex]) { const speed = VIDEO_PLAYBACK_SPEEDS[newSpeedIndex]; - videoRef.current.playbackRate = speed; + media.playbackRate = speed; setPlaybackSpeedIndex(newSpeedIndex); infoDialog(`${speed}x`); @@ -240,6 +256,7 @@ const VideoPlayer = ({ }); useKeyPress('f', () => { + if (audioMode) return; if (videoRef.current && videoRef.current.requestFullscreen && !document.fullscreenElement) { videoRef.current.requestFullscreen().catch(e => { console.error(e); @@ -254,15 +271,10 @@ const VideoPlayer = ({ }); useKeyPress('c', () => { - if (!videoRef.current) { - return; - } + if (audioMode || !videoRef.current) return; const tracks = [...videoRef.current.textTracks]; - - if (tracks.length === 0) { - return; - } + if (tracks.length === 0) return; const lastIndex = tracks.findIndex(x => x.mode === 'showing'); const active = tracks[lastIndex]; @@ -277,20 +289,18 @@ const VideoPlayer = ({ }); useKeyPress('ArrowLeft', () => { - const currentCurrentTime = videoRef.current?.currentTime; - - if (currentCurrentTime !== undefined && videoRef.current) { + const media = mediaRef(); + if (media?.currentTime !== undefined) { infoDialog('- 5 seconds'); - videoRef.current.currentTime = currentCurrentTime - 5; + media.currentTime -= 5; } }); useKeyPress('ArrowRight', () => { - const currentCurrentTime = videoRef.current?.currentTime; - - if (currentCurrentTime !== undefined && videoRef.current) { + const media = mediaRef(); + if (media?.currentTime !== undefined) { infoDialog('+ 5 seconds'); - videoRef.current.currentTime = currentCurrentTime + 5; + media.currentTime += 5; } }); @@ -337,13 +347,13 @@ const VideoPlayer = ({ }); }); - const handleVideoEnd = + const handleMediaEnd = ( youtubeId: string, watched: boolean, setSponsorSegmentSkipped?: Dispatch>, ) => - async (videoTag: VideoTag) => { + async (videoTag: VideoTag | AudioTag) => { const currentTime = Number(videoTag.currentTarget.currentTime); const videoProgressResponse = await updateVideoProgressById({ @@ -377,57 +387,147 @@ const VideoPlayer = ({ className={embed ? '' : `player-wrapper ${isTheaterMode ? 'theater-mode' : ''}`} >
-
diff --git a/frontend/src/style.css b/frontend/src/style.css index 74f0113a..8504f625 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -925,7 +925,8 @@ video:-webkit-full-screen { background: rgba(0, 0, 0, 0.5); } -.video-main video { +.video-main video, +.video-audio-toggle-wrap video { max-height: 70vh; margin-bottom: 1rem; } @@ -1531,7 +1532,97 @@ video:-webkit-full-screen { justify-content: space-between; } -/** loading indicator */ +/* audio mode toggle & card */ +.video-audio-toggle-wrap { + position: relative; + width: 90%; + max-width: 1500px; + margin: 0 auto; +} + +.video-audio-toggle-wrap video { + display: block; + width: 100%; + margin: 0; +} + +.audio-mode-icon-btn { + position: absolute; + bottom: 46px; /* sit above the native controls bar (~40px) */ + right: 8px; + width: 36px; + height: 36px; + padding: 6px; + background-color: rgba(0, 0, 0, 0.55); + border: none; + border-radius: 4px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background-color 0.2s; + z-index: 10; +} + +.audio-mode-icon-btn:hover { + background-color: rgba(0, 0, 0, 0.85); + transform: none; +} + +.audio-mode-icon-btn img { + width: 100%; + height: 100%; + filter: invert(1); +} + +.audio-card { + display: flex; + flex-direction: column; + background-color: var(--highlight-bg); + width: 90%; + max-width: 1500px; + margin: 0 auto; + box-sizing: border-box; +} + +.audio-card-thumb-wrap { + position: relative; + cursor: pointer; +} + +.audio-card-thumb-wrap::after { + content: ''; + position: absolute; + inset: 0; + background-color: rgba(0, 0, 0, 0); + transition: background-color 0.2s; +} + +.audio-card-thumb-wrap:hover::after { + background-color: rgba(0, 0, 0, 0.25); +} + +.audio-card-thumb { + width: 100%; + aspect-ratio: 16 / 9; + object-fit: cover; + display: block; +} + +.audio-card-thumb-wrap .audio-mode-icon-btn { + bottom: 12px; + right: 12px; +} + +.audio-card-controls { + padding: 1rem 1.5rem; +} + +.audio-card-controls audio { + width: 100%; + accent-color: var(--accent-font-dark); +} + /** source: https://github.com/loadingio/css-spinner/ */ .lds-ring, .lds-ring div {