Add audio only mode

This commit is contained in:
Merlin Scheurer 2026-04-20 13:02:33 +02:00
parent ca8d3dbd32
commit 3c1bdfb264
5 changed files with 379 additions and 77 deletions

View File

@ -30,4 +30,9 @@ urlpatterns = [
views.VideoSimilarView.as_view(),
name="api-video-similar",
),
path(
"<slug:video_id>/stream-mp3/",
views.VideoStreamMp3View.as_view(),
name="api-video-stream-mp3",
),
]

View File

@ -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/<video_id>/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

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<svg version="1.1" id="svg-icon-audio"
xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" viewBox="0 0 500 500"
style="enable-background:new 0 0 500 500;" xml:space="preserve">
<g>
<!-- Headband arc -->
<path d="M250,60c-104.9,0-190,85.1-190,190v10c0,11,9,20,20,20h10c11,0,20-9,20-20v-10
c0-77.3,62.7-140,140-140s140,62.7,140,140v10c0,11,9,20,20,20h10c11,0,20-9,20-20v-10
C440,145.1,354.9,60,250,60z"/>
<!-- Left ear cup -->
<rect x="60" y="270" width="80" height="110" rx="20" ry="20"/>
<!-- Right ear cup -->
<rect x="360" y="270" width="80" height="110" rx="20" ry="20"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 640 B

View File

@ -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<HTMLVideoElement, Event>;
type AudioTag = SyntheticEvent<HTMLAudioElement, Event>;
export type SkippedSegmentType = {
from: number;
@ -128,6 +129,17 @@ const VideoPlayer = ({
setSeekToTimestamp,
}: VideoPlayerProps) => {
const videoRef = useRef<HTMLVideoElement | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(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<SetStateAction<SponsorSegmentsSkippedType>>,
) =>
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' : ''}`}
>
<div className={embed ? '' : `video-main ${isTheaterMode ? 'theater-mode' : ''}`}>
<video
ref={videoRef}
key={`${getApiUrl()}${videoUrl}`}
poster={`${getApiUrl()}${videoThumbUrl}`}
onVolumeChange={(videoTag: VideoTag) => {
localStorage.setItem('playerVolume', videoTag.currentTarget.volume.toString());
}}
onRateChange={(videoTag: VideoTag) => {
localStorage.setItem('playerSpeed', videoTag.currentTarget.playbackRate.toString());
}}
onLoadStart={(videoTag: VideoTag) => {
videoTag.currentTarget.volume = volumeFromStorage;
videoTag.currentTarget.playbackRate = Number(playBackSpeedFromStorage ?? 1);
}}
onTimeUpdate={handleTimeUpdate(
videoId,
watched,
sponsorBlock,
setSkippedSegments,
onWatchStateChanged,
)}
onPause={async (videoTag: VideoTag) => {
const currentTime = Number(videoTag.currentTarget.currentTime);
{!audioMode && (
<div className="video-audio-toggle-wrap">
<video
ref={videoRef}
key={`${getApiUrl()}${videoUrl}`}
poster={`${getApiUrl()}${videoThumbUrl}`}
onVolumeChange={(videoTag: VideoTag) => {
localStorage.setItem('playerVolume', videoTag.currentTarget.volume.toString());
}}
onRateChange={(videoTag: VideoTag) => {
localStorage.setItem(
'playerSpeed',
videoTag.currentTarget.playbackRate.toString(),
);
}}
onLoadStart={(videoTag: VideoTag) => {
videoTag.currentTarget.volume = volumeFromStorage;
videoTag.currentTarget.playbackRate = Number(playBackSpeedFromStorage ?? 1);
}}
onTimeUpdate={handleTimeUpdate(
videoId,
watched,
sponsorBlock,
setSkippedSegments,
onWatchStateChanged,
)}
onPause={async (videoTag: VideoTag) => {
const currentTime = Number(videoTag.currentTarget.currentTime);
if (currentTime < 10 || currentTime > duration * 0.95) return;
if (currentTime < 10 || currentTime > duration * 0.95) return;
await updateVideoProgressById({
youtubeId: videoId,
currentProgress: currentTime,
});
}}
onEnded={handleVideoEnd(videoId, watched)}
onKeyDown={e => {
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault();
}
}}
autoPlay={autoplay}
controls
width="100%"
playsInline
id="video-item"
muted={isMuted}
>
<source
src={`${getApiUrl()}${videoUrl}#t=${videoSrcProgress}`}
type="video/mp4"
id="video-source"
/>
{videoSubtitles && <Subtitles subtitles={videoSubtitles} />}
</video>
await updateVideoProgressById({
youtubeId: videoId,
currentProgress: currentTime,
});
}}
onEnded={handleMediaEnd(videoId, watched)}
onKeyDown={e => {
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault();
}
}}
autoPlay={autoplay}
controls
width="100%"
playsInline
id="video-item"
muted={isMuted}
>
<source
src={`${getApiUrl()}${videoUrl}#t=${videoSrcProgress}`}
type="video/mp4"
id="video-source"
/>
{videoSubtitles && <Subtitles subtitles={videoSubtitles} />}
</video>
<button
className="audio-mode-icon-btn"
onClick={() => toggleAudioMode(true)}
title="Switch to audio only"
>
<img src="/img/icon-audio.svg" alt="Audio only" />
</button>
</div>
)}
{audioMode && (
<div className="audio-card">
<div
className="audio-card-thumb-wrap"
onClick={() => {
const audio = audioRef.current;
if (!audio) return;
if (audio.paused) {
audio.play();
} else {
audio.pause();
}
}}
>
<img
className="audio-card-thumb"
src={`${getApiUrl()}${videoThumbUrl}`}
alt={video.title}
/>
<button
className="audio-mode-icon-btn"
onClick={e => {
e.stopPropagation();
toggleAudioMode(false);
}}
title="Switch to video"
>
<img src="/img/icon-play.svg" alt="Switch to video" />
</button>
</div>
<div className="audio-card-controls">
<audio
ref={audioRef}
onVolumeChange={(e: AudioTag) => {
localStorage.setItem('playerVolume', e.currentTarget.volume.toString());
}}
onRateChange={(e: AudioTag) => {
localStorage.setItem('playerSpeed', e.currentTarget.playbackRate.toString());
}}
onLoadStart={(e: AudioTag) => {
e.currentTarget.volume = volumeFromStorage;
e.currentTarget.playbackRate = Number(playBackSpeedFromStorage ?? 1);
}}
onTimeUpdate={handleTimeUpdate(
videoId,
watched,
sponsorBlock,
setSkippedSegments,
onWatchStateChanged,
)}
onPause={async (videoTag: AudioTag) => {
const currentTime = Number(videoTag.currentTarget.currentTime);
if (currentTime < 10 || currentTime > duration * 0.95) return;
await updateVideoProgressById({
youtubeId: videoId,
currentProgress: currentTime,
});
}}
onEnded={handleMediaEnd(videoId, watched)}
onKeyDown={e => {
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault();
}
}}
autoPlay={autoplay}
controls
src={`${getApiUrl()}/api/video/${videoId}/stream-mp3/`}
muted={isMuted}
/>
</div>
</div>
)}
</div>
</div>

View File

@ -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 {