Video player with playback speed, PIP and Download support (#497)

* video playback support with play button in details view and torrent card

* added translation keys and minor bug fix

* removed unused "play" string

* updated required go dependency

* rearranged go dependencies to remove the warnings

* reverted the build related changes

* Added Playback speed, PIP and Dowload support

* added translation keys for all languages and updated getVideoCaption function logic

---------

Co-authored-by: nikk <1551446+tsynik@users.noreply.github.com>
This commit is contained in:
Uttam Deshani
2025-07-10 19:27:11 +05:30
committed by GitHub
parent 9ce9062a9e
commit c2d8426b9c
10 changed files with 598 additions and 17 deletions

View File

@@ -16,6 +16,7 @@ require (
github.com/anacrolix/log v0.16.0
github.com/anacrolix/missinggo/v2 v2.8.0
github.com/anacrolix/publicip v0.3.1
github.com/kljensen/snowball v0.9.0
github.com/anacrolix/torrent v1.58.1
github.com/dustin/go-humanize v1.0.1
github.com/gin-contrib/cors v1.7.5
@@ -36,6 +37,11 @@ require (
gopkg.in/vansante/go-ffprobe.v2 v2.2.1
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
google.golang.org/protobuf v1.34.1 // indirect
)
require (
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/RoaringBitmap/roaring v1.9.4 // indirect

View File

@@ -411,6 +411,7 @@ github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/ianlancetaylor/demangle v0.0.0-20220319035150-800ac71e25c2/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
@@ -426,6 +427,7 @@ github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVY
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
@@ -465,6 +467,7 @@ github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcME
github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
@@ -538,6 +541,7 @@ github.com/prometheus/procfs v0.0.11/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4
github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
@@ -741,6 +745,7 @@ golang.org/x/net v0.0.0-20220325170049-de3da57026de/go.mod h1:CfG3xpIq0wQ8r1q4Su
golang.org/x/net v0.0.0-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220520000938-2e3eb7b945c2/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220524220425-1d687d428aca/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=

View File

@@ -6,9 +6,10 @@ import { Button } from '@material-ui/core'
import CopyToClipboard from 'react-copy-to-clipboard'
import { useTranslation } from 'react-i18next'
import VideoPlayer from '../../VideoPlayer'
import { TableStyle, ShortTableWrapper, ShortTable } from './style'
const { memo } = require('react')
const { memo, useState } = require('react')
// russian episode detection support
ptt.addHandler('episode', /(\d{1,4})[- |. ]серия|серия[- |. ](\d{1,4})/i, { type: 'integer' })
@@ -18,6 +19,7 @@ ptt.addHandler('season', /сезон[- |. ](\d{1,3})|(\d{1,3})[- |. ]сезон/
const Table = memo(
({ playableFileList, viewedFileList, selectedSeason, seasonAmount, hash }) => {
const { t } = useTranslation()
const [isSupported, setIsSupported] = useState(true)
const preloadBuffer = fileId => fetch(`${streamHost()}?link=${hash}&index=${fileId}&preload`)
const getFileLink = (path, id) =>
`${streamHost()}/${encodeURIComponent(path.split('\\').pop().split('/').pop())}?link=${hash}&index=${id}&play`
@@ -67,13 +69,15 @@ const Table = memo(
<Button onClick={() => preloadBuffer(id)} variant='outlined' color='primary' size='small'>
{t('Preload')}
</Button>
<a style={{ textDecoration: 'none' }} href={link} target='_blank' rel='noreferrer'>
<Button style={{ width: '100%' }} variant='outlined' color='primary' size='small'>
{t('OpenLink')}
</Button>
</a>
{isSupported ? (
<VideoPlayer title={title} videoSrc={link} onNotSupported={() => setIsSupported(false)} />
) : (
<a style={{ textDecoration: 'none' }} href={link} target='_blank' rel='noreferrer'>
<Button style={{ width: '100%' }} variant='outlined' color='primary' size='small'>
{t('OpenLink')}
</Button>
</a>
)}
<CopyToClipboard text={link}>
<Button variant='outlined' color='primary' size='small'>
{t('CopyLink')}

View File

@@ -6,7 +6,7 @@ import {
Delete as DeleteIcon,
} from '@material-ui/icons'
import { getPeerString, humanizeSize, humanizeSpeed, removeRedundantCharacters } from 'utils/Utils'
import { playlistTorrHost, torrentsHost } from 'utils/Hosts'
import { playlistTorrHost, streamHost, torrentsHost } from 'utils/Hosts'
import { NoImageIcon } from 'icons'
import DialogTorrentDetailsContent from 'components/DialogTorrentDetailsContent'
import Dialog from '@material-ui/core/Dialog'
@@ -20,6 +20,8 @@ import { StyledDialog } from 'style/CustomMaterialUiStyles'
import useOnStandaloneAppOutsideClick from 'utils/useOnStandaloneAppOutsideClick'
import { GETTING_INFO, IN_DB, CLOSED, PRELOAD, WORKING } from 'torrentStates'
import { TORRENT_CATEGORIES } from 'components/categories'
import VideoPlayer from 'components/VideoPlayer'
import { isFilePlayable } from 'components/DialogTorrentDetailsContent/helpers'
import {
StatusIndicators,
@@ -36,6 +38,7 @@ const Torrent = ({ torrent }) => {
const { t } = useTranslation()
const [isDetailedInfoOpened, setIsDetailedInfoOpened] = useState(false)
const [isDeleteTorrentOpened, setIsDeleteTorrentOpened] = useState(false)
const [isSupported, setIsSupported] = useState(true)
const theme = useTheme()
const fullScreen = useMediaQuery(theme.breakpoints.down('md'))
@@ -85,7 +88,18 @@ const Torrent = ({ torrent }) => {
// main categories
const catIndex = TORRENT_CATEGORIES.findIndex(e => e.key === category)
const catArray = TORRENT_CATEGORIES.find(e => e.key === category)
const getFileLink = (path, id) =>
`${streamHost()}/${encodeURIComponent(path.split('\\').pop().split('/').pop())}?link=${hash}&index=${id}&play`
const fileList = torrent?.data ? JSON.parse(torrent?.data)?.TorrServer?.Files : []
const playableVideoList = fileList.length ? fileList?.filter(({ path }) => isFilePlayable(path)) : []
const getVideoCaption = path => {
// Get base name without extension
const baseName = path.replace(/\.[^/.]+$/, '')
// Find a file with the same base name and a subtitle extension
const captionFile = fileList.find(file => file.path.startsWith(baseName) && /\.(srt|vtt)$/i.test(file.path))
return captionFile ? getFileLink(captionFile.path, captionFile.id) : ''
}
return (
<>
<TorrentCard>
@@ -99,14 +113,23 @@ const Torrent = ({ torrent }) => {
<span>{t('Details')}</span>
</StyledButton>
<StyledButton
onClick={() => {
window.open(fullPlaylistLink, '_blank')
}}
>
<PlayArrowIcon />
<span>{t('Playlist')}</span>
</StyledButton>
{playableVideoList?.length === 1 && isSupported ? (
<VideoPlayer
title={title}
videoSrc={getFileLink(playableVideoList[0].path, playableVideoList[0].id)}
captionSrc={getVideoCaption(playableVideoList[0].path)}
onNotSupported={() => setIsSupported(false)}
/>
) : (
<StyledButton
onClick={() => {
window.open(fullPlaylistLink, '_blank')
}}
>
<PlayArrowIcon />
<span>{t('Playlist')}</span>
</StyledButton>
)}
<StyledButton onClick={() => dropTorrent(torrent)}>
<CloseIcon />

View File

@@ -0,0 +1,493 @@
import {
Box,
CircularProgress,
DialogContent,
DialogTitle,
IconButton,
Menu,
MenuItem,
Slider,
Tooltip,
Typography,
useMediaQuery,
} from '@material-ui/core'
import { makeStyles, withStyles } from '@material-ui/core/styles'
import CloseIcon from '@material-ui/icons/Close'
import Forward10Icon from '@material-ui/icons/Forward10'
import FullscreenIcon from '@material-ui/icons/Fullscreen'
import FullscreenExitIcon from '@material-ui/icons/FullscreenExit'
import GetAppIcon from '@material-ui/icons/GetApp'
import PauseIcon from '@material-ui/icons/Pause'
import PictureInPictureIcon from '@material-ui/icons/PictureInPicture'
import PlayArrowIcon from '@material-ui/icons/PlayArrow'
import Replay10Icon from '@material-ui/icons/Replay10'
import SpeedIcon from '@material-ui/icons/Speed'
import VolumeOffIcon from '@material-ui/icons/VolumeOff'
import VolumeUpIcon from '@material-ui/icons/VolumeUp'
import { useCallback, useEffect, useRef, useState } from 'react'
import { StyledDialog } from 'style/CustomMaterialUiStyles'
import { useTranslation } from 'react-i18next'
import { StyledButton } from './TorrentCard/style'
function getMimeType(url) {
const ext = url.split('?')[0].split('.').pop().toLowerCase()
switch (ext) {
case 'mp4':
return 'video/mp4'
case 'ogg':
return 'video/ogg'
case 'webm':
return 'video/webm'
default:
return ''
}
}
const PrettoSlider = withStyles(theme => ({
root: {
color: '#00a572',
height: 6,
[theme?.breakpoints?.down?.('sm')]: {
height: 0,
},
},
thumb: {
height: 18,
width: 18,
backgroundColor: '#fff',
border: '2px solid currentColor',
marginTop: -6,
marginLeft: -12,
[theme?.breakpoints?.down?.('sm')]: {
height: 15,
width: 15,
marginTop: -5,
marginLeft: -7,
},
},
track: {
height: 6,
borderRadius: 4,
[theme?.breakpoints?.down?.('sm')]: {
height: 5,
},
},
rail: {
height: 6,
borderRadius: 4,
[theme?.breakpoints?.down?.('sm')]: {
height: 6,
},
},
}))(Slider)
const useStyles = makeStyles(theme => ({
dialogPaper: {
backgroundColor: '#fff',
borderRadius: theme.spacing(1),
},
header: {
backgroundColor: '#00a572',
color: '#fff',
padding: theme.spacing(1, 2),
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
},
videoWrapper: {
position: 'relative',
width: '100%',
backgroundColor: '#000',
overflow: 'hidden',
'&:hover $controls, &:hover $centralControl, &:hover $skipButton': {
opacity: 1,
},
},
video: {
width: '100%',
display: 'block',
cursor: 'pointer',
[theme.breakpoints.down('sm')]: {
height: '94.5vh',
width: '100vw',
objectFit: 'contain',
},
},
loadingOverlay: {
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0,0,0,0.6)',
zIndex: 4,
},
centralControl: {
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
borderRadius: '50%',
padding: theme.spacing(1),
backgroundColor: 'rgba(0,0,0,0.5)',
opacity: 0,
transition: 'opacity 200ms',
zIndex: 3,
color: '#fff',
pointerEvents: 'none',
animation: '$pulse 0.6s ease-out',
},
skipButton: {
position: 'absolute',
top: '50%',
transform: 'translateY(-50%)',
padding: theme.spacing(1),
backgroundColor: 'rgba(0,0,0,0.4)',
color: '#fff',
opacity: 0,
transition: 'opacity 200ms',
zIndex: 3,
'&:hover': { backgroundColor: 'rgba(0,0,0,0.6)' },
},
leftSkip: { left: theme.spacing(2) },
rightSkip: { right: theme.spacing(2) },
controls: {
position: 'absolute',
bottom: 0,
left: 0,
width: '100%',
background: 'linear-gradient(to top, rgba(0,0,0,0.8), transparent)',
padding: theme.spacing(0, 3, 2, 3),
transition: 'opacity 200ms',
opacity: 0,
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(0.5),
zIndex: 3,
pointerEvents: 'auto',
[theme.breakpoints.down('sm')]: {
opacity: 1,
padding: theme.spacing(0, 1, 2, 1),
gap: theme.spacing(0),
background: 'linear-gradient(to top, rgba(0,0,0,0.95), transparent)',
},
},
timeRow: {
color: '#fff',
paddingLeft: theme.spacing(2),
[theme.breakpoints.down('sm')]: {
paddingLeft: theme.spacing(1),
fontSize: 9,
},
},
slider: {
color: '#00e68a',
'& .MuiSlider-thumb': { backgroundColor: '#00e68a' },
'& .MuiSlider-track': { borderRadius: 2 },
},
controlRow: {
display: 'flex',
alignItems: 'center',
},
iconButton: {
color: '#fff',
padding: 12,
'&:hover': { backgroundColor: 'rgba(255,255,255,0.1)' },
[theme.breakpoints.down('sm')]: {
padding: 10,
},
},
speedMenu: { minWidth: 100 },
'@keyframes pulse': {
'0%': { transform: 'translate(-50%, -50%) scale(0.5)', opacity: 0 },
'50%': { transform: 'translate(-50%, -50%) scale(1)', opacity: 1 },
'100%': { transform: 'translate(-50%, -50%) scale(1.3)', opacity: 0 },
},
}))
// Helper function to format seconds to HH:MM:SS
const formatTime = seconds => {
if (!isFinite(seconds)) return '00:00:00'
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = Math.floor(seconds % 60)
const hh = h.toString().padStart(2, '0')
const mm = m.toString().padStart(2, '0')
const ss = s.toString().padStart(2, '0')
return `${hh}:${mm}:${ss}`
}
const VideoPlayer = ({ videoSrc, captionSrc = '', title, onNotSupported }) => {
const classes = useStyles()
const isMobile = useMediaQuery('@media (max-width:930px)')
const videoRef = useRef(null)
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(true)
const [playing, setPlaying] = useState(false)
const [currentTime, setCurrentTime] = useState(0)
const [duration, setDuration] = useState(0)
const [muted, setMuted] = useState(false)
const [volume, setVolume] = useState(1)
const [fullscreen, setFullscreen] = useState(false)
const [anchorEl, setAnchorEl] = useState(null)
const [speed, setSpeed] = useState(1)
useEffect(() => {
const vid = document.createElement('video')
if (!vid.canPlayType(getMimeType(videoSrc))) onNotSupported()
}, [videoSrc, onNotSupported])
const handlePlayPause = () => {
if (!videoRef.current) return
playing ? videoRef.current.pause() : videoRef.current.play()
}
const togglePlay = () => setPlaying(p => !p)
const handleTimeUpdate = () => setCurrentTime(videoRef.current.currentTime)
const handleLoaded = () => {
setDuration(videoRef.current.duration)
setLoading(false)
}
const handleSeek = (_, val) => {
videoRef.current.currentTime = val
handleTimeUpdate()
}
const handleVolume = (_, val) => {
const v = val / 100
videoRef.current.volume = v
setVolume(v)
setMuted(v === 0)
}
const toggleMute = () => {
videoRef.current.muted = !muted
setMuted(m => !m)
}
const skip = secs => {
videoRef.current.currentTime = Math.min(Math.max(videoRef.current.currentTime + secs, 0), duration)
handleTimeUpdate()
}
const enterFull = () => videoRef.current.requestFullscreen()
const exitFull = () => document.exitFullscreen()
useEffect(() => {
const onFull = () => setFullscreen(!!document.fullscreenElement)
document.addEventListener('fullscreenchange', onFull)
return () => document.removeEventListener('fullscreenchange', onFull)
}, [])
const openSpeedMenu = e => setAnchorEl(e.currentTarget)
const closeSpeedMenu = () => setAnchorEl(null)
const changeSpeed = val => {
videoRef.current.playbackRate = val
setSpeed(val)
closeSpeedMenu()
}
const downloadVideo = () => {
const a = document.createElement('a')
a.href = videoSrc
a.download = ''
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
const handleKey = useCallback(
e => {
if (!open) return
switch (e.key) {
case ' ':
e.preventDefault()
handlePlayPause()
break
case 'ArrowRight':
e.preventDefault()
skip(10)
break
case 'ArrowLeft':
e.preventDefault()
skip(-10)
break
default:
break
}
},
[open, duration, playing],
)
useEffect(() => {
document.addEventListener('keydown', handleKey)
return () => document.removeEventListener('keydown', handleKey)
}, [handleKey])
return (
<>
<StyledButton onClick={() => setOpen(true)}>
<PlayArrowIcon />
<span>{t('Play')}</span>
</StyledButton>
<StyledDialog
open={open}
onClose={() => setOpen(false)}
maxWidth='lg'
fullWidth
fullScreen={isMobile}
classes={{ paper: classes.dialogPaper }}
>
<DialogTitle className={classes.header} disableTypography>
<Typography variant='h6' noWrap>
{title || 'Video Player'}
</Typography>
<IconButton size='medium' onClick={() => setOpen(false)} className={classes.iconButton}>
<CloseIcon fontSize='medium' />
</IconButton>
</DialogTitle>
<DialogContent style={{ padding: 0 }}>
<Box className={classes.videoWrapper} onClick={handlePlayPause} style={isMobile ? { minHeight: 240 } : {}}>
<video
autoPlay
ref={videoRef}
src={videoSrc}
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoaded}
onPlay={togglePlay}
onPause={togglePlay}
className={classes.video}
>
<track kind='captions' srcLang='en' label='English captions' src={captionSrc} default />
</video>
{loading && (
<Box className={classes.loadingOverlay}>
<CircularProgress fontSize='medium' />
</Box>
)}
<IconButton
size='medium'
className={classes.centralControl}
style={{
opacity: playing ? 0 : 1,
}}
>
<PlayArrowIcon fontSize='medium' />
</IconButton>
<Box className={classes.controls} onClick={e => e.stopPropagation()}>
{isMobile && (
<Box className={classes.timeRow}>
<Typography variant='body2'>
{formatTime(currentTime)} / {formatTime(duration)}
</Typography>
</Box>
)}
<PrettoSlider
className={classes.slider}
value={currentTime}
max={duration}
onChange={handleSeek}
size='medium'
/>
<Box className={classes.controlRow}>
<Tooltip title={playing ? t('Pause') : t('Play')}>
<IconButton size='medium' onClick={handlePlayPause} className={classes.iconButton}>
{playing ? <PauseIcon fontSize='medium' /> : <PlayArrowIcon fontSize='medium' />}
</IconButton>
</Tooltip>
<Tooltip title={t('Rewind-10-Sec')}>
<IconButton
size='medium'
className={classes.iconButton}
onClick={e => {
e.stopPropagation()
skip(-10)
}}
>
<Replay10Icon fontSize='medium' />
</IconButton>
</Tooltip>
<Tooltip title={t('Forward-10-Sec')}>
<IconButton
size='medium'
className={classes.iconButton}
onClick={e => {
e.stopPropagation()
skip(10)
}}
>
<Forward10Icon fontSize='medium' />
</IconButton>
</Tooltip>
<Tooltip title={muted ? t('Unmute') : t('Mute')}>
<IconButton size='medium' className={classes.iconButton} onClick={toggleMute}>
{muted ? <VolumeOffIcon fontSize='medium' /> : <VolumeUpIcon fontSize='medium' />}
</IconButton>
</Tooltip>
{!isMobile && (
<Slider
className={classes.slider}
value={volume * 100}
onChange={handleVolume}
size='medium'
style={{ width: 70 }}
/>
)}
{!isMobile && (
<Box className={classes.timeRow}>
<Typography variant='body2'>
{formatTime(currentTime)} / {formatTime(duration)}
</Typography>
</Box>
)}
<Box flexGrow={1} />
<Tooltip title={t('Speed')}>
<IconButton size='medium' onClick={openSpeedMenu} className={classes.iconButton}>
<SpeedIcon fontSize='medium' />
</IconButton>
</Tooltip>
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={closeSpeedMenu}
className={classes.speedMenu}
>
{[0.5, 1, 1.5, 2].map(r => (
<MenuItem key={r} selected={r === speed} onClick={() => changeSpeed(r)}>
{r}x
</MenuItem>
))}
</Menu>
<Tooltip title={t('PIP')}>
<IconButton
size='medium'
className={classes.iconButton}
onClick={() => videoRef.current.requestPictureInPicture()}
>
<PictureInPictureIcon fontSize='medium' />
</IconButton>
</Tooltip>
<Tooltip title={t('Download')}>
<IconButton size='medium' className={classes.iconButton} onClick={downloadVideo}>
<GetAppIcon fontSize='medium' />
</IconButton>
</Tooltip>
<Tooltip title={fullscreen ? t('ExitFullscreen') : t('Fullscreen')}>
<IconButton size='medium' onClick={fullscreen ? exitFull : enterFull} className={classes.iconButton}>
{fullscreen ? <FullscreenExitIcon fontSize='medium' /> : <FullscreenIcon fontSize='medium' />}
</IconButton>
</Tooltip>
</Box>
</Box>
</Box>
</DialogContent>
</StyledDialog>
</>
)
}
export default VideoPlayer

View File

@@ -83,6 +83,16 @@
"PiecesCount": "Брой парчета",
"PiecesLength": "Дължина на парчетата",
"Playlist": "Плейлист",
"Play":"Възпроизвеждане",
"Pause":"Пауза",
"Rewind-10-Sec":"Върни 10 сек",
"Forward-10-Sec":"Напред 10 сек",
"Unmute":"Включи звук",
"Mute":"Изключи звук",
"PIP":"PIP",
"Download":"Изтегляне",
"ExitFullscreen":"Изход от цял екран",
"Fullscreen":"Цял екран",
"Preload": "Предварително зареждане",
"ProjectSource": "GitHub на проекта",
"PWAGuide": {

View File

@@ -83,6 +83,16 @@
"PiecesCount": "Pieces count",
"PiecesLength": "Pieces length",
"Playlist": "Playlist",
"Play":"Play",
"Pause":"Pause",
"Rewind-10-Sec":"Rewind 10 Sec",
"Forward-10-Sec":"Forward 10 Sec",
"Unmute":"Unmute",
"Mute":"Mute",
"PIP":"Picture-In-Picture",
"Download":"Download",
"ExitFullscreen":"Exit Fullscreen",
"Fullscreen":"Fullscreen",
"Preload": "Preload",
"ProjectSource": "Project GitHub",
"PWAGuide": {

View File

@@ -83,6 +83,16 @@
"PiecesCount": "Кол-во блоков",
"PiecesLength": "Размер блока",
"Playlist": "Плейлист",
"Play":"Воспроизвести",
"Pause":"Пауза",
"Rewind-10-Sec":"Перемотать на 10 сек",
"Forward-10-Sec":"Перемотать на 10 сек",
"Unmute":"Включить звук",
"Mute":"Выключить звук",
"PIP":"Картинка в картинке",
"Download":"Скачать",
"ExitFullscreen":"Выход из полноэкранного режима",
"Fullscreen":"Полноэкранный режим",
"Preload": "Предзагр.",
"ProjectSource": "GitHub проекта",
"PWAGuide": {

View File

@@ -83,6 +83,16 @@
"PiecesCount": "К-сть блоків",
"PiecesLength": "Розмір блоку",
"Playlist": "Плейлист",
"Play":"Відтворити",
"Pause":"Пауза",
"Rewind-10-Sec":"Перемотати на 10 сек",
"Forward-10-Sec":"Перемотати на 10 сек",
"Unmute":"Включити звук",
"Mute":"Вимкнути звук",
"PIP":"Картинка в картинці",
"Download":"Завантажити",
"ExitFullscreen":"Вийти з повноекранного режиму",
"Fullscreen":"Повноекранний режим",
"Preload": "Передзав.",
"ProjectSource": "Сайт проекту",
"PWAGuide": {

View File

@@ -83,6 +83,16 @@
"PiecesCount": "块数量",
"PiecesLength": "块长度",
"Playlist": "播放列表",
"Play":"播放",
"Pause":"暂停",
"Rewind-10-Sec":"倒退 10 秒",
"Forward-10-Sec":"快进 10 秒",
"Unmute":"取消静音",
"Mute":"静音",
"PIP":"画中画",
"Download":"下载",
"ExitFullscreen":"退出全屏",
"Fullscreen":"全屏",
"Preload": "预加载",
"ProjectSource": "项目GitHub",
"PWAGuide": {