Merge branch 'master' into feature/sort-file-alphanumerically-1185
This commit is contained in:
@@ -3,7 +3,7 @@ const path = require('path')
|
||||
const prettyBytes = require('prettier-bytes')
|
||||
const React = require('react')
|
||||
|
||||
const {dispatch, dispatcher} = require('../lib/dispatcher')
|
||||
const { dispatch, dispatcher } = require('../lib/dispatcher')
|
||||
|
||||
const FlatButton = require('material-ui/FlatButton').default
|
||||
const RaisedButton = require('material-ui/RaisedButton').default
|
||||
@@ -35,7 +35,7 @@ class CreateTorrentPage extends React.Component {
|
||||
// Then, exclude .DS_Store and other dotfiles
|
||||
const files = info.files
|
||||
.filter((f) => !containsDots(f.path, pathPrefix))
|
||||
.map((f) => ({name: f.name, path: f.path, size: f.size}))
|
||||
.map((f) => ({ name: f.name, path: f.path, size: f.size }))
|
||||
if (files.length === 0) return (<CreateTorrentErrorPage state={state} />)
|
||||
|
||||
// Then, use the name of the base folder (or sole file, for a single file torrent)
|
||||
@@ -65,9 +65,9 @@ class CreateTorrentPage extends React.Component {
|
||||
}
|
||||
|
||||
// Create React event handlers only once
|
||||
this.setIsPrivate = (_, isPrivate) => this.setState({isPrivate})
|
||||
this.setComment = (_, comment) => this.setState({comment})
|
||||
this.setTrackers = (_, trackers) => this.setState({trackers})
|
||||
this.setIsPrivate = (_, isPrivate) => this.setState({ isPrivate })
|
||||
this.setComment = (_, comment) => this.setState({ comment })
|
||||
this.setTrackers = (_, trackers) => this.setState({ trackers })
|
||||
this.handleSubmit = handleSubmit.bind(this)
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@ class CreateTorrentPage extends React.Component {
|
||||
<label>Private:</label>
|
||||
<Checkbox
|
||||
className='torrent-is-private control'
|
||||
style={{display: ''}}
|
||||
style={{ display: '' }}
|
||||
checked={this.state.isPrivate}
|
||||
onCheck={this.setIsPrivate} />
|
||||
</div>
|
||||
@@ -191,9 +191,14 @@ function handleSubmit () {
|
||||
path: this.state.basePath,
|
||||
files: this.state.files,
|
||||
announce: announceList,
|
||||
private: this.state.isPrivate,
|
||||
comment: this.state.comment.trim()
|
||||
}
|
||||
|
||||
// If torrent is not private, leave private flag unset. This ensures that
|
||||
// the torrent info hash will match the result generated by other tools,
|
||||
// including webtorrent-cli.
|
||||
if (this.state.isPrivate) options.private = true
|
||||
|
||||
dispatch('createTorrent', options)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ const zeroFill = require('zero-fill')
|
||||
|
||||
const TorrentSummary = require('../lib/torrent-summary')
|
||||
const Playlist = require('../lib/playlist')
|
||||
const {dispatch, dispatcher} = require('../lib/dispatcher')
|
||||
const { dispatch, dispatcher } = require('../lib/dispatcher')
|
||||
const config = require('../../config')
|
||||
|
||||
// Shows a streaming video player. Standard features + Chromecast + Airplay
|
||||
@@ -160,6 +160,7 @@ function renderMedia (state) {
|
||||
} else {
|
||||
// When the last video completes, pause the video instead of looping
|
||||
state.playing.isPaused = true
|
||||
if (state.window.isFullScreen) dispatch('toggleFullScreen')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,28 +204,41 @@ function renderOverlay (state) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render track or disk number string
|
||||
* @param common metadata.common part
|
||||
* @param key should be either 'track' or 'disk'
|
||||
* @return track or disk number metadata as JSX block
|
||||
*/
|
||||
function renderTrack (common, key) {
|
||||
// Audio metadata: track-number
|
||||
if (common[key] && common[key].no) {
|
||||
let str = `${common[key].no}`
|
||||
if (common[key].of) {
|
||||
str += ` of ${common[key].of}`
|
||||
}
|
||||
const style = { textTransform: 'capitalize' }
|
||||
return (
|
||||
<div className={`audio-${key}`}>
|
||||
<label style={style}>{key}</label> {str}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function renderAudioMetadata (state) {
|
||||
const fileSummary = state.getPlayingFileSummary()
|
||||
if (!fileSummary.audioInfo) return
|
||||
const info = fileSummary.audioInfo
|
||||
const common = fileSummary.audioInfo.common || {}
|
||||
|
||||
// Get audio track info
|
||||
let title = info.title
|
||||
if (!title) {
|
||||
title = fileSummary.name
|
||||
}
|
||||
let artist = info.artist && info.artist[0]
|
||||
let album = info.album
|
||||
if (album && info.year && !album.includes(info.year)) {
|
||||
album += ' (' + info.year + ')'
|
||||
}
|
||||
let track
|
||||
if (info.track && info.track.no && info.track.of) {
|
||||
track = info.track.no + ' of ' + info.track.of
|
||||
}
|
||||
const title = common.title ? common.title : fileSummary.name
|
||||
|
||||
// Show a small info box in the middle of the screen with title/album/etc
|
||||
const elems = []
|
||||
|
||||
// Audio metadata: artist(s)
|
||||
const artist = common.artist || common.albumartist
|
||||
if (artist) {
|
||||
elems.push((
|
||||
<div key='artist' className='audio-artist'>
|
||||
@@ -232,17 +246,86 @@ function renderAudioMetadata (state) {
|
||||
</div>
|
||||
))
|
||||
}
|
||||
if (album) {
|
||||
|
||||
// Audio metadata: disk & track-number
|
||||
const count = ['track', 'disk']
|
||||
count.forEach(key => {
|
||||
const nrElem = renderTrack(common, key)
|
||||
if (nrElem) {
|
||||
elems.push(nrElem)
|
||||
}
|
||||
})
|
||||
|
||||
// Audio metadata: album
|
||||
if (common.album) {
|
||||
elems.push((
|
||||
<div key='album' className='audio-album'>
|
||||
<label>Album</label>{album}
|
||||
<label>Album</label>{common.album}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
if (track) {
|
||||
|
||||
// Audio metadata: year
|
||||
if (common.year) {
|
||||
elems.push((
|
||||
<div key='track' className='audio-track'>
|
||||
<label>Track</label>{track}
|
||||
<div key='year' className='audio-year'>
|
||||
<label>Year</label>{common.year}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
|
||||
// Audio metadata: release information (label & catalog-number)
|
||||
if (common.label || common.catalognumber) {
|
||||
const releaseInfo = []
|
||||
if (common.label && common.catalognumber &&
|
||||
common.label.length === common.catalognumber.length) {
|
||||
// Assume labels & catalog-numbers are pairs
|
||||
for (let n = 0; n < common.label.length; ++n) {
|
||||
releaseInfo.push(common.label[0] + ' / ' + common.catalognumber[n])
|
||||
}
|
||||
} else {
|
||||
if (common.label) {
|
||||
releaseInfo.push(...common.label)
|
||||
}
|
||||
if (common.catalognumber) {
|
||||
releaseInfo.push(...common.catalognumber)
|
||||
}
|
||||
}
|
||||
elems.push((
|
||||
<div key='release' className='audio-release'>
|
||||
<label>Release</label>{ releaseInfo.join(', ') }
|
||||
</div>
|
||||
))
|
||||
}
|
||||
|
||||
// Audio metadata: format
|
||||
const format = []
|
||||
fileSummary.audioInfo.format = fileSummary.audioInfo.format || ''
|
||||
if (fileSummary.audioInfo.format.dataformat) {
|
||||
format.push(fileSummary.audioInfo.format.dataformat)
|
||||
}
|
||||
if (fileSummary.audioInfo.format.bitrate) {
|
||||
format.push(Math.round(fileSummary.audioInfo.format.bitrate / 1000) + ' kbps') // 128 kbps
|
||||
}
|
||||
if (fileSummary.audioInfo.format.sampleRate) {
|
||||
format.push(Math.round(fileSummary.audioInfo.format.sampleRate / 100) / 10 + ' kHz') // 44.1 kHz
|
||||
}
|
||||
if (fileSummary.audioInfo.format.bitsPerSample) {
|
||||
format.push(fileSummary.audioInfo.format.bitsPerSample + ' bit')
|
||||
}
|
||||
if (format.length > 0) {
|
||||
elems.push((
|
||||
<div key='format' className='audio-format'>
|
||||
<label>Format</label>{ format.join(', ') }
|
||||
</div>
|
||||
))
|
||||
}
|
||||
|
||||
// Audio metadata: comments
|
||||
if (common.comment) {
|
||||
elems.push((
|
||||
<div key='comments' className='audio-comments'>
|
||||
<label>Comments</label>{common.comment.join(' / ')}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
@@ -273,7 +356,7 @@ function renderLoadingSpinner (state) {
|
||||
|
||||
return (
|
||||
<div key='loading' className='media-stalled'>
|
||||
<div key='loading-spinner' className='loading-spinner'> </div>
|
||||
<div key='loading-spinner' className='loading-spinner' />
|
||||
<div key='loading-progress' className='loading-status ellipsis'>
|
||||
<span className='progress'>{fileProgress}%</span> downloaded
|
||||
<span> ↓ {prettyBytes(prog.downloadSpeed || 0)}/s</span>
|
||||
@@ -303,7 +386,7 @@ function renderCastScreen (state) {
|
||||
isCast = false
|
||||
} else if (state.playing.location === 'error') {
|
||||
castIcon = 'error_outline'
|
||||
castType = 'Error'
|
||||
castType = 'Unable to Play'
|
||||
isCast = false
|
||||
}
|
||||
|
||||
@@ -333,7 +416,7 @@ function renderCastScreen (state) {
|
||||
function renderCastOptions (state) {
|
||||
if (!state.devices.castMenu) return
|
||||
|
||||
const {location, devices} = state.devices.castMenu
|
||||
const { location, devices } = state.devices.castMenu
|
||||
const player = state.devices[location]
|
||||
|
||||
const items = devices.map(function (device, ix) {
|
||||
@@ -456,9 +539,9 @@ function renderPlayerControls (state) {
|
||||
|
||||
// Add the cast buttons. Icons for each cast type, connected/disconnected:
|
||||
const buttonIcons = {
|
||||
'chromecast': {true: 'cast_connected', false: 'cast'},
|
||||
'airplay': {true: 'airplay', false: 'airplay'},
|
||||
'dlna': {true: 'tv', false: 'tv'}
|
||||
'chromecast': { true: 'cast_connected', false: 'cast' },
|
||||
'airplay': { true: 'airplay', false: 'airplay' },
|
||||
'dlna': { true: 'tv', false: 'tv' }
|
||||
}
|
||||
castTypes.forEach(function (castType) {
|
||||
// Do we show this button (eg. the Chromecast button) at all?
|
||||
@@ -497,9 +580,9 @@ function renderPlayerControls (state) {
|
||||
const volume = state.playing.volume
|
||||
const volumeIcon = 'volume_' + (
|
||||
volume === 0 ? 'off'
|
||||
: volume < 0.3 ? 'mute'
|
||||
: volume < 0.6 ? 'down'
|
||||
: 'up')
|
||||
: volume < 0.3 ? 'mute'
|
||||
: volume < 0.6 ? 'down'
|
||||
: 'up')
|
||||
const volumeStyle = {
|
||||
background: '-webkit-gradient(linear, left top, right top, ' +
|
||||
'color-stop(' + (volume * 100) + '%, #eee), ' +
|
||||
@@ -523,8 +606,8 @@ function renderPlayerControls (state) {
|
||||
))
|
||||
|
||||
// Show video playback progress
|
||||
const currentTimeStr = formatTime(state.playing.currentTime)
|
||||
const durationStr = formatTime(state.playing.duration)
|
||||
const currentTimeStr = formatTime(state.playing.currentTime, state.playing.duration)
|
||||
const durationStr = formatTime(state.playing.duration, state.playing.duration)
|
||||
elements.push((
|
||||
<span key='time' className='time float-left'>
|
||||
{currentTimeStr} / {durationStr}
|
||||
@@ -613,7 +696,7 @@ function renderLoadingBar (state) {
|
||||
for (let i = fileProg.startPiece; i <= fileProg.endPiece; i++) {
|
||||
const partPresent = Bitfield.prototype.get.call(prog.bitfield, i)
|
||||
if (partPresent && !lastPiecePresent) {
|
||||
parts.push({start: i - fileProg.startPiece, count: 1})
|
||||
parts.push({ start: i - fileProg.startPiece, count: 1 })
|
||||
} else if (partPresent) {
|
||||
parts[parts.length - 1].count++
|
||||
}
|
||||
@@ -646,17 +729,19 @@ function cssBackgroundImageDarkGradient () {
|
||||
'rgba(0,0,0,0.4) 0%, rgba(0,0,0,1) 100%)'
|
||||
}
|
||||
|
||||
function formatTime (time) {
|
||||
function formatTime (time, total) {
|
||||
if (typeof time !== 'number' || Number.isNaN(time)) {
|
||||
return '0:00'
|
||||
}
|
||||
|
||||
let hours = Math.floor(time / 3600)
|
||||
const totalHours = Math.floor(total / 3600)
|
||||
const totalMinutes = Math.floor(total / 60)
|
||||
const hours = Math.floor(time / 3600)
|
||||
let minutes = Math.floor(time % 3600 / 60)
|
||||
if (hours > 0) {
|
||||
if (totalMinutes > 9) {
|
||||
minutes = zeroFill(2, minutes)
|
||||
}
|
||||
let seconds = zeroFill(2, Math.floor(time % 60))
|
||||
const seconds = zeroFill(2, Math.floor(time % 60))
|
||||
|
||||
return (hours > 0 ? hours + ':' : '') + minutes + ':' + seconds
|
||||
return (totalHours > 0 ? hours + ':' : '') + minutes + ':' + seconds
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const path = require('path')
|
||||
const React = require('react')
|
||||
const PropTypes = require('prop-types')
|
||||
|
||||
const colors = require('material-ui/styles/colors')
|
||||
const Checkbox = require('material-ui/Checkbox').default
|
||||
@@ -7,7 +8,7 @@ const RaisedButton = require('material-ui/RaisedButton').default
|
||||
const Heading = require('../components/heading')
|
||||
const PathSelector = require('../components/path-selector')
|
||||
|
||||
const {dispatch} = require('../lib/dispatcher')
|
||||
const { dispatch } = require('../lib/dispatcher')
|
||||
const config = require('../../config')
|
||||
|
||||
class PreferencesPage extends React.Component {
|
||||
@@ -37,7 +38,7 @@ class PreferencesPage extends React.Component {
|
||||
}}
|
||||
onChange={this.handleDownloadPathChange}
|
||||
title='Download location'
|
||||
value={this.props.state.unsaved.prefs.downloadPath} />
|
||||
value={this.props.state.saved.prefs.downloadPath} />
|
||||
</Preference>
|
||||
)
|
||||
}
|
||||
@@ -51,7 +52,7 @@ class PreferencesPage extends React.Component {
|
||||
<Preference>
|
||||
<Checkbox
|
||||
className='control'
|
||||
checked={!this.props.state.unsaved.prefs.openExternalPlayer}
|
||||
checked={!this.props.state.saved.prefs.openExternalPlayer}
|
||||
label={'Play torrent media files using WebTorrent'}
|
||||
onCheck={this.handleOpenExternalPlayerChange} />
|
||||
</Preference>
|
||||
@@ -67,7 +68,7 @@ class PreferencesPage extends React.Component {
|
||||
<Preference>
|
||||
<Checkbox
|
||||
className='control'
|
||||
checked={this.props.state.unsaved.prefs.highestPlaybackPriority}
|
||||
checked={this.props.state.saved.prefs.highestPlaybackPriority}
|
||||
label={'Highest Playback Priority'}
|
||||
onCheck={this.handleHighestPlaybackPriorityChange}
|
||||
/>
|
||||
@@ -81,10 +82,10 @@ class PreferencesPage extends React.Component {
|
||||
}
|
||||
|
||||
externalPlayerPathSelector () {
|
||||
const playerPath = this.props.state.unsaved.prefs.externalPlayerPath
|
||||
const playerPath = this.props.state.saved.prefs.externalPlayerPath
|
||||
const playerName = this.props.state.getExternalPlayerName()
|
||||
|
||||
const description = this.props.state.unsaved.prefs.openExternalPlayer
|
||||
const description = this.props.state.saved.prefs.openExternalPlayer
|
||||
? `Torrent media files will always play in ${playerName}.`
|
||||
: `Torrent media files will play in ${playerName} if WebTorrent cannot play them.`
|
||||
|
||||
@@ -108,8 +109,61 @@ class PreferencesPage extends React.Component {
|
||||
dispatch('updatePreferences', 'externalPlayerPath', filePath)
|
||||
}
|
||||
|
||||
autoAddTorrentsCheckbox () {
|
||||
return (
|
||||
<Preference>
|
||||
<Checkbox
|
||||
className='control'
|
||||
checked={this.props.state.saved.prefs.autoAddTorrents}
|
||||
label={'Watch for new .torrent files and add them immediately'}
|
||||
onCheck={(e, value) => { this.handleAutoAddTorrentsChange(e, value) }}
|
||||
/>
|
||||
</Preference>
|
||||
)
|
||||
}
|
||||
|
||||
handleAutoAddTorrentsChange (e, isChecked) {
|
||||
const torrentsFolderPath = this.props.state.saved.prefs.torrentsFolderPath
|
||||
if (isChecked && !torrentsFolderPath) {
|
||||
alert('Select a torrents folder first.') // eslint-disable-line
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
dispatch('updatePreferences', 'autoAddTorrents', isChecked)
|
||||
|
||||
if (isChecked) {
|
||||
dispatch('startFolderWatcher', null)
|
||||
return
|
||||
}
|
||||
|
||||
dispatch('stopFolderWatcher', null)
|
||||
}
|
||||
|
||||
torrentsFolderPathSelector () {
|
||||
const torrentsFolderPath = this.props.state.saved.prefs.torrentsFolderPath
|
||||
|
||||
return (
|
||||
<Preference>
|
||||
<PathSelector
|
||||
dialog={{
|
||||
title: 'Select folder to watch for new torrents',
|
||||
properties: [ 'openDirectory' ]
|
||||
}}
|
||||
displayValue={torrentsFolderPath || ''}
|
||||
onChange={this.handletorrentsFolderPathChange}
|
||||
title='Folder to watch'
|
||||
value={torrentsFolderPath ? path.dirname(torrentsFolderPath) : null} />
|
||||
</Preference>
|
||||
)
|
||||
}
|
||||
|
||||
handletorrentsFolderPathChange (filePath) {
|
||||
dispatch('updatePreferences', 'torrentsFolderPath', filePath)
|
||||
}
|
||||
|
||||
setDefaultAppButton () {
|
||||
const isFileHandler = this.props.state.unsaved.prefs.isFileHandler
|
||||
const isFileHandler = this.props.state.saved.prefs.isFileHandler
|
||||
if (isFileHandler) {
|
||||
return (
|
||||
<Preference>
|
||||
@@ -142,7 +196,7 @@ class PreferencesPage extends React.Component {
|
||||
<Preference>
|
||||
<Checkbox
|
||||
className='control'
|
||||
checked={this.props.state.unsaved.prefs.startup}
|
||||
checked={this.props.state.saved.prefs.startup}
|
||||
label={'Open WebTorrent on startup.'}
|
||||
onCheck={this.handleStartupChange}
|
||||
/>
|
||||
@@ -163,8 +217,10 @@ class PreferencesPage extends React.Component {
|
||||
}
|
||||
return (
|
||||
<div style={style}>
|
||||
<PreferencesSection title='Downloads'>
|
||||
<PreferencesSection title='Folders'>
|
||||
{this.downloadPathSelector()}
|
||||
{this.autoAddTorrentsCheckbox()}
|
||||
{this.torrentsFolderPathSelector()}
|
||||
</PreferencesSection>
|
||||
<PreferencesSection title='Playback'>
|
||||
{this.openExternalPlayerCheckbox()}
|
||||
@@ -183,7 +239,7 @@ class PreferencesPage extends React.Component {
|
||||
class PreferencesSection extends React.Component {
|
||||
static get propTypes () {
|
||||
return {
|
||||
title: React.PropTypes.string
|
||||
title: PropTypes.string
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ const LinearProgress = require('material-ui/LinearProgress').default
|
||||
|
||||
const TorrentSummary = require('../lib/torrent-summary')
|
||||
const TorrentPlayer = require('../lib/torrent-player')
|
||||
const {dispatcher} = require('../lib/dispatcher')
|
||||
const { dispatcher } = require('../lib/dispatcher')
|
||||
|
||||
module.exports = class TorrentList extends React.Component {
|
||||
render () {
|
||||
@@ -148,7 +148,7 @@ module.exports = class TorrentList extends React.Component {
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div style={styles.wrapper}>
|
||||
<div key='progress-bar' style={styles.wrapper}>
|
||||
<LinearProgress style={styles.progress} mode='determinate' value={progress} />
|
||||
</div>
|
||||
)
|
||||
@@ -201,7 +201,7 @@ module.exports = class TorrentList extends React.Component {
|
||||
const minutesStr = (hours || minutes) ? minutes + 'm' : ''
|
||||
const secondsStr = seconds + 's'
|
||||
|
||||
return (<span>{hoursStr} {minutesStr} {secondsStr} remaining</span>)
|
||||
return (<span key='eta'>{hoursStr} {minutesStr} {secondsStr} remaining</span>)
|
||||
}
|
||||
|
||||
function renderTorrentStatus () {
|
||||
@@ -217,7 +217,7 @@ module.exports = class TorrentList extends React.Component {
|
||||
} else { // torrentSummary.status is 'new' or something unexpected
|
||||
status = ''
|
||||
}
|
||||
return (<span>{status}</span>)
|
||||
return (<span key='torrent-status'>{status}</span>)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,21 +368,21 @@ module.exports = class TorrentList extends React.Component {
|
||||
|
||||
renderRadialProgressBar (fraction, cssClass) {
|
||||
const rotation = 360 * fraction
|
||||
const transformFill = {transform: 'rotate(' + (rotation / 2) + 'deg)'}
|
||||
const transformFix = {transform: 'rotate(' + rotation + 'deg)'}
|
||||
const transformFill = { transform: 'rotate(' + (rotation / 2) + 'deg)' }
|
||||
const transformFix = { transform: 'rotate(' + rotation + 'deg)' }
|
||||
|
||||
return (
|
||||
<div key='radial-progress' className={'radial-progress ' + cssClass}>
|
||||
<div key='circle' className='circle'>
|
||||
<div key='mask-full' className='mask full' style={transformFill}>
|
||||
<div key='fill' className='fill' style={transformFill} />
|
||||
<div className='circle'>
|
||||
<div className='mask full' style={transformFill}>
|
||||
<div className='fill' style={transformFill} />
|
||||
</div>
|
||||
<div key='mask-half' className='mask half'>
|
||||
<div key='fill' className='fill' style={transformFill} />
|
||||
<div key='fill-fix' className='fill fix' style={transformFix} />
|
||||
<div className='mask half'>
|
||||
<div className='fill' style={transformFill} />
|
||||
<div className='fill fix' style={transformFix} />
|
||||
</div>
|
||||
</div>
|
||||
<div key='inset' className='inset' />
|
||||
<div className='inset' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -396,7 +396,7 @@ function getErrorMessage (torrentSummary) {
|
||||
const err = torrentSummary.error
|
||||
if (err === 'path-missing') {
|
||||
return (
|
||||
<span>
|
||||
<span key='path-missing'>
|
||||
Path missing.<br />
|
||||
Fix and restart the app, or delete the torrent.
|
||||
</span>
|
||||
|
||||
Reference in New Issue
Block a user