Add audio only mode
This commit is contained in:
parent
ca8d3dbd32
commit
3c1bdfb264
|
|
@ -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",
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue