From b65a24df9642d5cfbbfe9081c9553cf2fddc6a5d Mon Sep 17 00:00:00 2001 From: John Preston Date: Wed, 13 Mar 2019 18:58:50 +0400 Subject: [PATCH] Allow streaming videos with unknown duration. When you stream image/gif as a soundless video the total duration is unknown, so we accumulate packet->pts + packet->duration as duration. --- .../streaming/media_streaming_audio_track.cpp | 9 +- .../streaming/media_streaming_audio_track.h | 2 +- .../media/streaming/media_streaming_common.h | 2 + .../media/streaming/media_streaming_file.cpp | 17 ++- .../streaming/media_streaming_player.cpp | 138 +++++++++++++++--- .../media/streaming/media_streaming_player.h | 14 +- .../streaming/media_streaming_utility.cpp | 23 ++- .../media/streaming/media_streaming_utility.h | 6 + .../streaming/media_streaming_video_track.cpp | 98 ++++++++++--- 9 files changed, 257 insertions(+), 52 deletions(-) diff --git a/Telegram/SourceFiles/media/streaming/media_streaming_audio_track.cpp b/Telegram/SourceFiles/media/streaming/media_streaming_audio_track.cpp index 3e8c95761..0875ffe0a 100644 --- a/Telegram/SourceFiles/media/streaming/media_streaming_audio_track.cpp +++ b/Telegram/SourceFiles/media/streaming/media_streaming_audio_track.cpp @@ -28,6 +28,7 @@ AudioTrack::AudioTrack( , _error(std::move(error)) , _playPosition(options.position) { Expects(_stream.duration > 1); + Expects(_stream.duration != kDurationUnavailable); // Not supported. Expects(_ready != nullptr); Expects(_error != nullptr); Expects(_audioId.externalPlayId() != 0); @@ -47,7 +48,9 @@ crl::time AudioTrack::streamDuration() const { } void AudioTrack::process(Packet &&packet) { - _noMoreData = packet.empty(); + if (packet.empty()) { + _readTillEnd = true; + } if (initialized()) { mixerEnqueue(std::move(packet)); } else if (!tryReadFirstFrame(std::move(packet))) { @@ -78,7 +81,7 @@ bool AudioTrack::tryReadFirstFrame(Packet &&packet) { // Return the last valid frame if we seek too far. _stream.frame = std::move(_initialSkippingFrame); return processFirstFrame(); - } else if (error.code() != AVERROR(EAGAIN) || _noMoreData) { + } else if (error.code() != AVERROR(EAGAIN) || _readTillEnd) { return false; } else { // Waiting for more packets. @@ -139,7 +142,7 @@ void AudioTrack::callReady() { auto data = AudioInformation(); data.state.duration = _stream.duration; data.state.position = _startedPosition; - data.state.receivedTill = _noMoreData + data.state.receivedTill = _readTillEnd ? _stream.duration : _startedPosition; base::take(_ready)({ VideoInformation(), data }); diff --git a/Telegram/SourceFiles/media/streaming/media_streaming_audio_track.h b/Telegram/SourceFiles/media/streaming/media_streaming_audio_track.h index c6b45d28f..c1629ab59 100644 --- a/Telegram/SourceFiles/media/streaming/media_streaming_audio_track.h +++ b/Telegram/SourceFiles/media/streaming/media_streaming_audio_track.h @@ -65,7 +65,7 @@ private: // Accessed from the same unspecified thread. Stream _stream; const AudioMsgId _audioId; - bool _noMoreData = false; + bool _readTillEnd = false; // Assumed to be thread-safe. FnMut _ready; diff --git a/Telegram/SourceFiles/media/streaming/media_streaming_common.h b/Telegram/SourceFiles/media/streaming/media_streaming_common.h index 50baea6d5..210c8bb89 100644 --- a/Telegram/SourceFiles/media/streaming/media_streaming_common.h +++ b/Telegram/SourceFiles/media/streaming/media_streaming_common.h @@ -10,6 +10,8 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL namespace Media { constexpr auto kTimeUnknown = std::numeric_limits::min(); +constexpr auto kDurationMax = crl::time(std::numeric_limits::max()); +constexpr auto kDurationUnavailable = std::numeric_limits::max(); namespace Audio { bool SupportsSpeedControl(); diff --git a/Telegram/SourceFiles/media/streaming/media_streaming_file.cpp b/Telegram/SourceFiles/media/streaming/media_streaming_file.cpp index db8f28f2c..8a1663662 100644 --- a/Telegram/SourceFiles/media/streaming/media_streaming_file.cpp +++ b/Telegram/SourceFiles/media/streaming/media_streaming_file.cpp @@ -135,13 +135,17 @@ Stream File::Context::initStream( result.duration = (info->duration != AV_NOPTS_VALUE) ? PtsToTime(info->duration, result.timeBase) : PtsToTime(format->duration, kUniversalTimeBase); - if (result.duration == kTimeUnknown || !result.duration) { + if (!result.duration) { result.codec = nullptr; - return result; + } else if (result.duration == kTimeUnknown) { + result.duration = kDurationUnavailable; + } else { + ++result.duration; + if (result.duration > kDurationMax) { + result.duration = 0; + result.codec = nullptr; + } } - // We want duration to be greater than any valid frame position. - // That way we can handle looping by advancing position by n * duration. - ++result.duration; return result; } @@ -153,6 +157,9 @@ void File::Context::seekToPosition( if (!position) { return; + } else if (stream.duration == kDurationUnavailable) { + // Seek in files with unknown duration is not supported. + return; } // // Non backward search reads the whole file if the position is after diff --git a/Telegram/SourceFiles/media/streaming/media_streaming_player.cpp b/Telegram/SourceFiles/media/streaming/media_streaming_player.cpp index 2ebd6a079..38baf8f6b 100644 --- a/Telegram/SourceFiles/media/streaming/media_streaming_player.cpp +++ b/Telegram/SourceFiles/media/streaming/media_streaming_player.cpp @@ -154,7 +154,7 @@ void Player::trackPlayedTill( if (guard && position != kTimeUnknown) { state.position = position; const auto value = _options.loop - ? (position % _totalDuration) + ? (position % computeTotalDuration()) : position; _updates.fire({ PlaybackUpdate{ value } }); } @@ -179,7 +179,7 @@ void Player::trackSendReceivedTill( state.receivedTill, _previousReceivedTill); const auto value = _options.loop - ? (receivedTill % _totalDuration) + ? (receivedTill % computeTotalDuration()) : receivedTill; _updates.fire({ PreloadedUpdate{ value } }); } @@ -230,13 +230,17 @@ bool Player::fileReady(Stream &&video, Stream &&audio) { }; }; const auto mode = _options.mode; - if (mode != Mode::Audio && mode != Mode::Both) { + if ((mode != Mode::Audio && mode != Mode::Both) + || audio.duration == kDurationUnavailable) { audio = Stream(); } if (mode != Mode::Video && mode != Mode::Both) { video = Stream(); } - if (audio.codec) { + if (audio.duration == kDurationUnavailable) { + LOG(("Streaming Error: Audio stream with unknown duration.")); + return false; + } else if (audio.codec) { if (_options.audioId.audio() != nullptr) { _audioId = AudioMsgId( _options.audioId.audio(), @@ -278,6 +282,11 @@ bool Player::fileReady(Stream &&video, Stream &&audio) { LOG(("Streaming Error: Required stream not found for mode %1." ).arg(int(mode))); return false; + } else if (_audio + && _video + && _video->streamDuration() == kDurationUnavailable) { + LOG(("Streaming Error: Both streams with unknown video duration.")); + return false; } _totalDuration = std::max( _audio ? _audio->streamDuration() : kTimeUnknown, @@ -315,34 +324,43 @@ bool Player::fileProcessPacket(Packet &&packet) { const auto index = native.stream_index; if (packet.empty()) { _readTillEnd = true; + setDurationByPackets(); if (_audio) { - const auto till = _loopingShift + _audio->streamDuration(); + const auto till = _loopingShift + computeAudioDuration(); crl::on_main(&_sessionGuard, [=] { audioReceivedTill(till); }); _audio->process(Packet()); } if (_video) { - const auto till = _loopingShift + _video->streamDuration(); + const auto till = _loopingShift + computeVideoDuration(); crl::on_main(&_sessionGuard, [=] { videoReceivedTill(till); }); _video->process(Packet()); } } else if (_audio && _audio->streamIndex() == native.stream_index) { + accumulate_max( + _durationByLastAudioPacket, + durationByPacket(*_audio, packet)); + const auto till = _loopingShift + std::clamp( PacketPosition(packet, _audio->streamTimeBase()), crl::time(0), - _audio->streamDuration() - 1); + computeAudioDuration() - 1); crl::on_main(&_sessionGuard, [=] { audioReceivedTill(till); }); _audio->process(std::move(packet)); } else if (_video && _video->streamIndex() == native.stream_index) { + accumulate_max( + _durationByLastVideoPacket, + durationByPacket(*_video, packet)); + const auto till = _loopingShift + std::clamp( PacketPosition(packet, _video->streamTimeBase()), crl::time(0), - _video->streamDuration() - 1); + computeVideoDuration() - 1); crl::on_main(&_sessionGuard, [=] { videoReceivedTill(till); }); @@ -353,8 +371,15 @@ bool Player::fileProcessPacket(Packet &&packet) { bool Player::fileReadMore() { if (_options.loop && _readTillEnd) { + const auto duration = computeTotalDuration(); + if (duration == kDurationUnavailable) { + LOG(("Streaming Error: " + "Couldn't find out the real stream duration.")); + fileError(Error::InvalidData); + return false; + } + _loopingShift += duration; _readTillEnd = false; - _loopingShift += _totalDuration; return true; } return !_readTillEnd && !_pauseReading; @@ -373,6 +398,40 @@ void Player::streamFailed(Error error) { } } +template +int Player::durationByPacket( + const Track &track, + const Packet &packet) { + // We've set this value on the first cycle. + if (_loopingShift || _totalDuration != kDurationUnavailable) { + return 0; + } + const auto result = DurationByPacket(packet, track.streamTimeBase()); + if (result < 0) { + fileError(Error::InvalidData); + return 0; + } + + Ensures(result > 0); + return result; +} + +void Player::setDurationByPackets() { + if (_loopingShift || _totalDuration != kDurationUnavailable) { + return; + } + const auto duration = std::max( + _durationByLastAudioPacket, + _durationByLastVideoPacket); + if (duration > 1) { + _durationByPackets = duration; + } else { + LOG(("Streaming Error: Bad total duration by packets: %1" + ).arg(duration)); + fileError(Error::InvalidData); + } +} + void Player::provideStartInformation() { Expects(_stage == Stage::Initializing); @@ -413,7 +472,7 @@ void Player::play(const PlaybackOptions &options) { // Looping video with audio is not supported for now. Expects(!options.loop || (options.mode != Mode::Both)); - const auto previous = getCurrentReceivedTill(); + const auto previous = getCurrentReceivedTill(computeTotalDuration()); stop(); _lastFailure = std::nullopt; @@ -442,6 +501,43 @@ crl::time Player::loadInAdvanceFor() const { return _remoteLoader ? kLoadInAdvanceForRemote : kLoadInAdvanceForLocal; } +crl::time Player::computeTotalDuration() const { + if (_totalDuration != kDurationUnavailable) { + return _totalDuration; + } else if (const auto byPackets = _durationByPackets.load()) { + return byPackets; + } + return kDurationUnavailable; +} + +crl::time Player::computeAudioDuration() const { + Expects(_audio != nullptr); + + const auto result = _audio->streamDuration(); + if (result != kDurationUnavailable) { + return result; + } else if ((_loopingShift || _readTillEnd) + && _durationByLastAudioPacket) { + // We looped, so it already holds full stream duration. + return _durationByLastAudioPacket; + } + return kDurationUnavailable; +} + +crl::time Player::computeVideoDuration() const { + Expects(_video != nullptr); + + const auto result = _video->streamDuration(); + if (result != kDurationUnavailable) { + return result; + } else if ((_loopingShift || _readTillEnd) + && _durationByLastVideoPacket) { + // We looped, so it already holds full stream duration. + return _durationByLastVideoPacket; + } + return kDurationUnavailable; +} + void Player::pause() { Expects(active()); @@ -609,6 +705,9 @@ void Player::stop() { _pauseReading = false; _readTillEnd = false; _loopingShift = 0; + _durationByPackets = 0; + _durationByLastAudioPacket = 0; + _durationByLastVideoPacket = 0; _information = Information(); } @@ -691,13 +790,17 @@ Media::Player::TrackState Player::prepareLegacyState() const { result.position = std::max( _information.audio.state.position, _information.video.state.position); + result.length = computeTotalDuration(); if (result.position == kTimeUnknown) { result.position = _options.position; - } else if (_options.loop && _totalDuration > 0) { - result.position %= _totalDuration; + } else if (_options.loop && result.length > 0) { + result.position %= result.length; } - result.receivedTill = _remoteLoader ? getCurrentReceivedTill() : 0; - result.length = _totalDuration; + result.receivedTill = _remoteLoader + ? getCurrentReceivedTill(result.length) + : 0; + result.frequency = kMsFrequency; + if (result.length == kTimeUnknown) { const auto document = _options.audioId.audio(); const auto duration = document ? document->getDuration() : 0; @@ -707,17 +810,16 @@ Media::Player::TrackState Player::prepareLegacyState() const { result.length = std::max(crl::time(result.position), crl::time(0)); } } - result.frequency = kMsFrequency; return result; } -crl::time Player::getCurrentReceivedTill() const { +crl::time Player::getCurrentReceivedTill(crl::time duration) const { const auto previous = std::max(_previousReceivedTill, crl::time(0)); const auto result = std::min( std::max(_information.audio.state.receivedTill, previous), std::max(_information.video.state.receivedTill, previous)); - return (result >= 0 && _totalDuration > 1 && _options.loop) - ? (result % _totalDuration) + return (result >= 0 && duration > 1 && _options.loop) + ? (result % duration) : result; } diff --git a/Telegram/SourceFiles/media/streaming/media_streaming_player.h b/Telegram/SourceFiles/media/streaming/media_streaming_player.h index 99210c35c..40d5f0732 100644 --- a/Telegram/SourceFiles/media/streaming/media_streaming_player.h +++ b/Telegram/SourceFiles/media/streaming/media_streaming_player.h @@ -107,12 +107,21 @@ private: [[nodiscard]] bool bothReceivedEnough(crl::time amount) const; [[nodiscard]] bool receivedTillEnd() const; void checkResumeFromWaitingForData(); - [[nodiscard]] crl::time getCurrentReceivedTill() const; + [[nodiscard]] crl::time getCurrentReceivedTill(crl::time duration) const; void savePreviousReceivedTill( const PlaybackOptions &options, crl::time previousReceivedTill); [[nodiscard]] crl::time loadInAdvanceFor() const; + template + int durationByPacket(const Track &track, const Packet &packet); + + // Valid after fileReady call ends. Thread-safe. + [[nodiscard]] crl::time computeAudioDuration() const; + [[nodiscard]] crl::time computeVideoDuration() const; + [[nodiscard]] crl::time computeTotalDuration() const; + void setDurationByPackets(); + template void trackReceivedTill( const Track &track, @@ -170,6 +179,9 @@ private: crl::time _totalDuration = kTimeUnknown; crl::time _loopingShift = 0; crl::time _previousReceivedTill = kTimeUnknown; + std::atomic _durationByPackets = 0; + int _durationByLastAudioPacket = 0; + int _durationByLastVideoPacket = 0; rpl::lifetime _lifetime; rpl::lifetime _sessionLifetime; diff --git a/Telegram/SourceFiles/media/streaming/media_streaming_utility.cpp b/Telegram/SourceFiles/media/streaming/media_streaming_utility.cpp index 5da6e7ca7..4dbf5f41b 100644 --- a/Telegram/SourceFiles/media/streaming/media_streaming_utility.cpp +++ b/Telegram/SourceFiles/media/streaming/media_streaming_utility.cpp @@ -289,6 +289,27 @@ crl::time PacketPosition(const Packet &packet, AVRational timeBase) { timeBase); } +crl::time PacketDuration(const Packet &packet, AVRational timeBase) { + return PtsToTime(packet.fields().duration, timeBase); +} + +int DurationByPacket(const Packet &packet, AVRational timeBase) { + const auto position = PacketPosition(packet, timeBase); + const auto duration = std::max( + PacketDuration(packet, timeBase), + crl::time(1)); + const auto bad = [](crl::time time) { + return (time < 0) || (time > kDurationMax); + }; + if (bad(position) || bad(duration) || bad(position + duration + 1)) { + LOG(("Streaming Error: Wrong duration by packet: %1 + %2" + ).arg(position + ).arg(duration)); + return -1; + } + return int(position + duration + 1); +} + crl::time FramePosition(const Stream &stream) { const auto pts = !stream.frame ? AV_NOPTS_VALUE @@ -432,7 +453,7 @@ QImage ConvertFrame( for (const auto y : ranges::view::ints(0, frame->height)) { for (const auto x : ranges::view::ints(0, frame->width)) { // Wipe out possible alpha values. - *to++ = 0x000000FFU | *from++; + *to++ = 0xFF000000U | *from++; } to += deltaTo; from += deltaFrom; diff --git a/Telegram/SourceFiles/media/streaming/media_streaming_utility.h b/Telegram/SourceFiles/media/streaming/media_streaming_utility.h index 8f16d1656..dbcc3ee97 100644 --- a/Telegram/SourceFiles/media/streaming/media_streaming_utility.h +++ b/Telegram/SourceFiles/media/streaming/media_streaming_utility.h @@ -191,6 +191,12 @@ void LogError(QLatin1String method, AvErrorWrap error); [[nodiscard]] crl::time PacketPosition( const Packet &packet, AVRational timeBase); +[[nodiscard]] crl::time PacketDuration( + const Packet &packet, + AVRational timeBase); +[[nodiscard]] int DurationByPacket( + const Packet &packet, + AVRational timeBase); [[nodiscard]] crl::time FramePosition(const Stream &stream); [[nodiscard]] int ReadRotationFromMetadata(not_null stream); [[nodiscard]] AVRational ValidateAspectRatio(AVRational aspect); diff --git a/Telegram/SourceFiles/media/streaming/media_streaming_video_track.cpp b/Telegram/SourceFiles/media/streaming/media_streaming_video_track.cpp index f0911f892..1c2081e1d 100644 --- a/Telegram/SourceFiles/media/streaming/media_streaming_video_track.cpp +++ b/Telegram/SourceFiles/media/streaming/media_streaming_video_track.cpp @@ -58,6 +58,7 @@ private: FrameResult, Shared::PrepareNextCheck>; + void fail(Error error); [[nodiscard]] bool interrupted() const; [[nodiscard]] bool tryReadFirstFrame(Packet &&packet); [[nodiscard]] bool fillStateFromFrame(); @@ -68,7 +69,9 @@ private: [[nodiscard]] FrameResult readFrame(not_null frame); void presentFrameIfNeeded(); void callReady(); - void loopAround(); + [[nodiscard]] bool loopAround(); + [[nodiscard]] crl::time computeDuration() const; + [[nodiscard]] int durationByPacket(const Packet &packet); // Force frame position to be clamped to [0, duration] and monotonic. [[nodiscard]] crl::time currentFramePosition() const; @@ -84,13 +87,14 @@ private: Stream _stream; AudioMsgId _audioId; - bool _noMoreData = false; + bool _readTillEnd = false; FnMut _ready; Fn _error; crl::time _pausedTime = kTimeUnknown; crl::time _resumedTime = kTimeUnknown; + int _durationByLastPacket = 0; mutable TimePoint _syncTimePoint; - crl::time _framePositionShift = 0; + crl::time _loopingShift = 0; rpl::event_stream<> _checkNextFrame; rpl::event_stream<> _waitingForData; FrameRequest _request; @@ -142,15 +146,39 @@ void VideoTrackObject::process(Packet &&packet) { if (interrupted()) { return; } - _noMoreData = packet.empty(); + if (packet.empty()) { + _readTillEnd = true; + } else if (!_readTillEnd) { + accumulate_max( + _durationByLastPacket, + durationByPacket(packet)); + if (interrupted()) { + return; + } + } if (_shared->initialized()) { _stream.queue.push_back(std::move(packet)); queueReadFrames(); } else if (!tryReadFirstFrame(std::move(packet))) { - _error(Error::InvalidData); + fail(Error::InvalidData); } } +int VideoTrackObject::durationByPacket(const Packet &packet) { + // We've set this value on the first cycle. + if (_loopingShift || _stream.duration != kDurationUnavailable) { + return 0; + } + const auto result = DurationByPacket(packet, _stream.timeBase); + if (result < 0) { + fail(Error::InvalidData); + return 0; + } + + Ensures(result > 0); + return result; +} + void VideoTrackObject::queueReadFrames(crl::time delay) { if (delay > 0) { _readFramesTimer.callOnce(delay); @@ -175,7 +203,9 @@ void VideoTrackObject::readFrames() { || result == FrameResult::Finished) { presentFrameIfNeeded(); } else if (result == FrameResult::Looped) { - time -= _stream.duration; + const auto duration = computeDuration(); + Assert(duration != kDurationUnavailable); + time -= duration; } }, [&](Shared::PrepareNextCheck delay) { Expects(delay == kTimeUnknown || delay > 0); @@ -211,25 +241,44 @@ auto VideoTrackObject::readEnoughFrames(crl::time trackTime) }); } -void VideoTrackObject::loopAround() { +bool VideoTrackObject::loopAround() { + const auto duration = computeDuration(); + if (duration == kDurationUnavailable) { + LOG(("Streaming Error: " + "Couldn't find out the real video stream duration.")); + return false; + } avcodec_flush_buffers(_stream.codec.get()); - _framePositionShift += _stream.duration; + _loopingShift += duration; + _readTillEnd = false; + return true; +} + +crl::time VideoTrackObject::computeDuration() const { + if (_stream.duration != kDurationUnavailable) { + return _stream.duration; + } else if ((_loopingShift || _readTillEnd) && _durationByLastPacket) { + // We looped, so it already holds full stream duration. + return _durationByLastPacket; + } + return kDurationUnavailable; } auto VideoTrackObject::readFrame(not_null frame) -> FrameResult { if (const auto error = ReadNextFrame(_stream)) { if (error.code() == AVERROR_EOF) { - if (_options.loop) { - loopAround(); - return FrameResult::Looped; - } else { + if (!_options.loop) { frame->position = kFinishedPosition; frame->displayed = kTimeUnknown; return FrameResult::Finished; + } else if (loopAround()) { + return FrameResult::Looped; + } else { + fail(Error::InvalidData); + return FrameResult::Error; } - } else if (error.code() != AVERROR(EAGAIN) || _noMoreData) { - interrupt(); - _error(Error::InvalidData); + } else if (error.code() != AVERROR(EAGAIN) || _readTillEnd) { + fail(Error::InvalidData); return FrameResult::Error; } Assert(_stream.queue.empty()); @@ -238,8 +287,7 @@ auto VideoTrackObject::readFrame(not_null frame) -> FrameResult { } const auto position = currentFramePosition(); if (position == kTimeUnknown) { - interrupt(); - _error(Error::InvalidData); + fail(Error::InvalidData); return FrameResult::Error; } std::swap(frame->decoded, _stream.frame); @@ -264,8 +312,7 @@ void VideoTrackObject::presentFrameIfNeeded() { std::move(frame->original)); if (frame->original.isNull()) { frame->prepared = QImage(); - interrupt(); - _error(Error::InvalidData); + fail(Error::InvalidData); return; } @@ -361,7 +408,7 @@ bool VideoTrackObject::tryReadFirstFrame(Packet &&packet) { // Return the last valid frame if we seek too far. _stream.frame = std::move(_initialSkippingFrame); return processFirstFrame(); - } else if (error.code() != AVERROR(EAGAIN) || _noMoreData) { + } else if (error.code() != AVERROR(EAGAIN) || _readTillEnd) { return false; } else { // Waiting for more packets. @@ -402,10 +449,10 @@ crl::time VideoTrackObject::currentFramePosition() const { if (position == kTimeUnknown || position == kFinishedPosition) { return kTimeUnknown; } - return _framePositionShift + std::clamp( + return _loopingShift + std::clamp( position, crl::time(0), - _stream.duration - 1); + computeDuration() - 1); } bool VideoTrackObject::fillStateFromFrame() { @@ -431,7 +478,7 @@ void VideoTrackObject::callReady() { data.rotation = _stream.rotation; data.state.duration = _stream.duration; data.state.position = _syncTimePoint.trackTime; - data.state.receivedTill = _noMoreData + data.state.receivedTill = _readTillEnd ? _stream.duration : _syncTimePoint.trackTime; base::take(_ready)({ data }); @@ -465,6 +512,11 @@ void VideoTrackObject::interrupt() { _shared = nullptr; } +void VideoTrackObject::fail(Error error) { + interrupt(); + _error(error); +} + void VideoTrack::Shared::init(QImage &&cover, crl::time position) { Expects(!initialized());