You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
kaffeine/kaffeine/src/player-parts/libmpv-part/libmpv_part.cpp

756 lines
23 KiB

/*
* Kaffeine libmpv part
* Copyright (C) 2023 Mavridis Philippe <mavridisf@gmail.com>
*
* Based on Kaffeine dummy part
* Copyright (C) 2004-2005 Jürgen Kofler <kaffeine@gmx.net>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
*/
// TQt
#include <tqtooltip.h>
#include <tqlabel.h>
#include <tqslider.h>
#include <tqpushbutton.h>
#include <tqfile.h>
#include <tqstringlist.h>
#include <tqpopupmenu.h>
#include <tqcursor.h>
#include <tqhbox.h>
// TDE
#include <tdeparts/genericfactory.h>
#include <tdeglobalsettings.h>
#include <tdeio/netaccess.h>
#include <kstandarddirs.h>
#include <tdemessagebox.h>
#include <tdefiledialog.h>
#include <tdeaction.h>
#include <kmimetype.h>
#include <tdemessagebox.h>
#include <kxmlguifactory.h>
#include <tdetoolbar.h>
#include <tdeglobal.h>
#include <kiconloader.h>
#include <kdebug.h>
// Kaffeine
#include "playlistimport.h"
// Part
#include "libmpv_part.h"
#include "libmpv_event.h"
#include "libmpv_widget.h"
#include "libmpv_log.h"
#include "libmpv_errordlg.h"
typedef KParts::GenericFactory<MpvPart> MpvPartFactory;
K_EXPORT_COMPONENT_FACTORY (libmpvpart, MpvPartFactory);
MpvPart::MpvPart(TQWidget* parentWidget, const char* widgetName, TQObject* parent, const char* name, const TQStringList& /*args*/)
: KaffeinePart(parent, name ? name : "MpvPart"),
m_current(0),
m_seeking(false),
m_seekpos(0),
m_recordFilePath(),
m_context(nullptr),
m_error(nullptr),
m_criticalError(nullptr),
m_playtimeMode(CurrentTime)
{
// Create an instance of this class
setInstance(MpvPartFactory::instance());
// Initialize GUI
setXMLFile("libmpv_part.rc");
initActions();
// Create container widget
m_player = new MpvContainerWidget(this, parentWidget);
setWidget(m_player);
// Initialize and embed mpv
if (!initMpv()) {
kdError() << "libmpvpart: initialization of mpv failed!" << endl;
emit canceled(i18n("MPV initialization failed!"));
return;
}
m_logoPath = locate( "data", "kaffeine/logo" );
kdDebug() << "libmpvpart: Found logo animation: " << m_logoPath << endl;
closeURL(); // displays logo
emit setStatusBarText(i18n("Ready"));
}
MpvPart::~MpvPart()
{
if (m_mpv) {
mpv_terminate_destroy(m_mpv);
m_mpv = nullptr;
}
if (m_eventThread) {
// the event thread doesn't do I/O, so we just kill it
m_eventThread->terminate();
}
}
TDEAboutData *MpvPart::createAboutData() {
TDEAboutData* aboutData = new TDEAboutData(
"libmpvpart", I18N_NOOP("MpvPart"),
"0.1", "A Kaffeine player part based on libmpv",
TDEAboutData::License_GPL,
"(c) 2023 Trinity Desktop Project", 0);
aboutData->addAuthor("Mavridis Philippe", I18N_NOOP("Developer"), "mavridisf@gmail.com");
aboutData->addAuthor("Jürgen Kofler", I18N_NOOP("Original Kaffeine developer"), "kaffeine@gmx.net");
return aboutData;
}
bool MpvPart::initMpv() {
// Create mpv instance
m_mpv = mpv_create();
if (!m_mpv) {
return false;
}
// Embed mpv into container widget
int64_t wid = static_cast<int64_t>(m_player->winId());
mpv_set_option(m_mpv, "wid", MPV_FORMAT_INT64, &wid);
// Get log messages with minimal level "info"
mpv_request_log_messages(m_mpv, "info");
// Start mpv event thread
m_eventThread = new MpvEventThread(this);
m_eventThread->start(TQThread::LowPriority);
return (mpv_initialize(m_mpv) >= 0);
}
void MpvPart::initActions()
{
/** Playback controls ***/
new TDEAction(i18n("Play"), "media-playback-start", 0, this, SLOT(slotPlay()), actionCollection(), "player_play");
new TDEAction(i18n("Pause"), "media-playback-pause", Key_Space, this, SLOT(slotTogglePause()), actionCollection(), "player_pause");
new TDEAction(i18n("Stop"), "media-playback-stop", Key_Backspace, this, SLOT(slotStop()), actionCollection(), "player_stop");
new TDEAction(i18n("&Previous"), "media-skip-backward", Key_PageUp, this, SLOT(slotPrevious()), actionCollection(), "player_previous");
new TDEAction(i18n("&Next"), "media-skip-forward", Key_PageDown, this, SLOT(slotNext()), actionCollection(), "player_next");
/*** Position toolbar ***/
// Important: we have a max of 1000 instead of 100 for better precision; multiply/divide your percentages by 10
m_position = new TQSlider(0, 100, 10, 0, TQt::Horizontal, 0);
TQToolTip::add(m_position, i18n("Position"));
m_position->setTracking(false);
m_position->setFocusPolicy(TQWidget::NoFocus);
m_position->setMinimumWidth(100);
connect(m_position, SIGNAL(sliderPressed()), this, SLOT(slotStartSeeking()));
connect(m_position, SIGNAL(sliderMoved(int)), this, SLOT(slotSetSeekingPos(int)));
connect(m_position, SIGNAL(sliderReleased()), this, SLOT(slotStopSeeking()));
KWidgetAction *posAction = new KWidgetAction(m_position, i18n("Position"), 0, 0, 0, actionCollection(), "player_position");
posAction->setAutoSized(true);
m_playtime = new TQPushButton(0);
TQToolTip::add(m_playtime, i18n("Press to change playtime display mode."));
TQFontMetrics met(TDEGlobalSettings::generalFont());
m_playtime->setFixedWidth(met.width(" -88:88:88 "));
m_playtime->setSizePolicy(TQSizePolicy (TQSizePolicy::Fixed, TQSizePolicy::Fixed));
m_playtime->setFocusPolicy(TQWidget::NoFocus);
connect(m_playtime, SIGNAL(pressed()), this, SLOT(slotTogglePlaytimeMode()));
new KWidgetAction(m_playtime, i18n("Playtime"), 0, 0, 0, actionCollection(), "player_playtime");
/*** Volume toolbar ***/
new TDEAction(i18n("&Mute"), "player_mute", Key_U, this, SLOT(slotMute()), actionCollection(), "player_mute");
m_volume = new TQSlider(0, 100, 10, 100, TQt::Horizontal, 0);
connect(m_volume, SIGNAL(sliderMoved(int)), this, SLOT(slotSetVolume(int)));
new KWidgetAction(m_volume, i18n("Volume"), 0, 0, 0, actionCollection(), "player_volume");
/*** Stream recording toolbar ***/
m_recordAction = new TDEToggleAction(i18n("&Record stream"), "player_record", Key_R, this, SLOT(slotToggleRecording()), actionCollection(), "record_toggle");
new TDEAction(i18n("Set recording file"), "document-open", 0, this, SLOT(slotSetRecordingFile()), actionCollection(), "record_open");
m_recordFile = new TQLabel(0);
new KWidgetAction(m_recordFile, i18n("Recording file"), 0, 0, 0, actionCollection(), "record_file");
updateRecordFileLabel();
/*** Context menu ***/
new TDEAction(i18n("Add subtitles"), "text-x-generic", 0, this, SLOT(slotAddSubtitles()), actionCollection(), "subtitles_add");
/*** Other ***/
new TDEAction(i18n("View MPV log"), "text-x-log", 0, this, SLOT(slotViewLog()), actionCollection(), "view_log");
resetTime();
stateChanged("disable_all");
}
void MpvPart::logEvent(struct MpvEventData eventData) {
m_eventLog.append(eventData);
}
void MpvPart::slotViewLog() {
MpvLogViewerDlg *logViewer = new MpvLogViewerDlg(this);
logViewer->exec();
}
void MpvPart::slotTogglePlaytimeMode() {
++m_playtimeMode;
if (m_playtimeMode == PLAYTIME_MODE_LAST) {
m_playtimeMode = CurrentTime;
}
updatePlaytime(m_time);
}
void MpvPart::updatePlaytime(TQTime time) {
TQString label;
switch (m_playtimeMode) {
case CurrentTime: {
label = time.toString();
break;
}
case RemainingTime: {
TQTime duration = m_mrl.length();
int h = duration.hour() - time.hour();
int m = duration.minute() - time.minute();
int s = duration.second() - time.second();
label = TQString("-%1:%2:%3").arg(TQString::number(h).rightJustify(2, '0'),
TQString::number(m).rightJustify(2, '0'),
TQString::number(s).rightJustify(2, '0'));
break;
}
}
m_playtime->setText(label);
}
// Custom events dispatched from mpv event thread are handled here
void MpvPart::customEvent(TQCustomEvent *event) {
if (event->type() == MPVPART_EVENT_PROPERTY_CHANGE) {
MpvPropertyChangeEvent *pe = (MpvPropertyChangeEvent *)event;
if (pe->property() == "time-pos" && pe->format() == MPV_FORMAT_DOUBLE) {
m_time = pe->toTime();
updatePlaytime(m_time);
}
else if (pe->property() == "percent-pos" && pe->format() == MPV_FORMAT_DOUBLE) {
if (!m_seeking) {
m_position->setValue(pe->toDouble());
}
}
else if (pe->property() == "duration" && pe->format() == MPV_FORMAT_DOUBLE) {
TQTime length = TQTime().addMSecs(pe->toDouble() * 1000);
if (!length.isNull()) {
m_mrl.setLength(length);
emit signalNewMeta(m_mrl);
}
}
}
else if (event->type() == MPVPART_EVENT_EOF) {
resetTime();
if (!m_mrl.isEmpty()) {
closeURL();
stateChanged("not_playing");
}
MpvEOFEvent *eofe = (MpvEOFEvent *)event;
if (eofe->reason() == MPV_END_FILE_REASON_ERROR) {
KMessageBox::detailedError(nullptr, i18n("Cannot play file."), eofe->error());
}
}
else if (event->type() == MPVPART_EVENT_ERROR) {
MpvErrorEvent *ee = (MpvErrorEvent *)event;
KDialogBase *edlg;
if (ee->fatal()) {
if (m_criticalError) {
return;
}
if (m_error) {
m_error->close();
}
edlg = new MpvFatalErrorDlg(this);
m_criticalError = edlg;
}
else {
if (m_error || m_criticalError) {
return;
}
edlg = new MpvErrorDlg(this);
m_error = edlg;
}
edlg->exec();
delete edlg;
if (ee->fatal()) {
m_criticalError = nullptr;
}
else {
m_error = nullptr;
}
}
}
bool MpvPart::openURL(const MRL& mrl) {
/* Note: we do not use the internal playlist feature of mpv.
* Instead we rely on the playlist feature of Kaffeine.
*/
if (!m_mpv) return false;
m_mrl = mrl;
m_playlist.clear();
m_current = 0;
/* Playlist handling code taken from gstreamer_part.cpp */
bool playlist = false;
TQString ext = m_mrl.kurl().fileName();
ext = ext.remove( 0 , ext.findRev('.')+1 ).lower();
if ( m_mrl.mime().isNull() ) {
KMimeType::Ptr mime = KMimeType::findByURL( m_mrl.kurl().path() );
m_mrl.setMime( mime->name() );
}
if ( (m_mrl.mime() == "text/plain") || (m_mrl.mime() == "text/xml") || (m_mrl.mime() == "application/x-kaffeine")
|| (m_mrl.mime() == "audio/x-scpls") || (m_mrl.mime() == "audio/x-mpegurl") || (m_mrl.mime() == "audio/mpegurl")
|| (ext == "asx") || (ext == "asf") || (ext == "wvx") || (ext == "wax") ) /* windows meta files */
{
kdDebug() << "libmpvpart: Check for kaffeine/noatun/m3u/pls/asx playlist\n";
TQString localFile;
if ( TDEIO::NetAccess::download(m_mrl.kurl(), localFile, widget()) ) {
TQFile file( localFile );
file.open( IO_ReadOnly );
TQTextStream stream( &file );
TQString firstLine = stream.readLine();
TQString secondLine = stream.readLine();
file.close();
if ( secondLine.contains("kaffeine", false) ) {
kdDebug() << "libmpvpart: Try loading kaffeine playlist\n";
playlist = PlaylistImport::kaffeine( localFile, m_playlist );
}
if ( secondLine.contains("noatun", false) ) {
kdDebug() << "libmpvpart: Try loading noatun playlist\n";
playlist = PlaylistImport::noatun( localFile, m_playlist);
}
if ( firstLine.contains("asx", false) ) {
kdDebug() << "libmpvpart: Try loading asx playlist\n";
playlist = PlaylistImport::asx( localFile, m_playlist );
}
if ( firstLine.contains("[playlist]", false) ) {
kdDebug() << "libmpvpart: Try loading pls playlist\n";
playlist = PlaylistImport::pls( localFile, m_playlist );
}
if (ext == "m3u" || ext == "m3u8") { //indentify by extension
kdDebug() << "libmpvpart: Try loading m3u playlist\n";
playlist = PlaylistImport::m3u( localFile, m_playlist );
}
}
else
kdError() << "libmpvpart: " << TDEIO::NetAccess::lastErrorString() << endl;
}
// check for ram playlist
if ( (ext == "ra") || (ext == "rm") || (ext == "ram") || (ext == "lsc") || (ext == "pl") ) {
kdDebug() << "libmpvpart: Try loading ram playlist\n";
playlist = PlaylistImport::ram( m_mrl, m_playlist, widget() );
}
if (!playlist)
{
kdDebug() << "libmpvpart: Got single track" << endl;
m_playlist.append( m_mrl );
}
m_mrl.reset();
slotPlay();
return true;
}
bool MpvPart::closeURL() {
if (!m_mpv) return false;
m_mrl.reset();
if (!m_logoPath.isNull()) {
const char *logo_args[] = {"loadfile", m_logoPath.local8Bit(), nullptr };
mpv_command_async(m_mpv, 0, logo_args);
slotPause(true);
}
return true;
}
void MpvPart::slotPlay() {
if (!m_mpv) return;
slotPause(false);
if (!m_mrl.isEmpty()) {
return;
}
if (m_playlist.count() > 0) {
emit setStatusBarText( i18n("Opening...") );
m_mrl = m_playlist[m_current];
const char *args[] = {"loadfile", m_mrl.url().local8Bit(), nullptr};
mpv_command_async(m_mpv, 0, args);
slotReloadSubtitles();
}
else {
emit signalRequestCurrentTrack();
}
slotReloadSubtitles();
stateChanged(isStream() ? "playing_stream" : "playing_file");
}
void MpvPart::slotPause(bool pause) {
int value = pause ? 1 : 0;
mpv_set_property(m_mpv, "pause", MPV_FORMAT_FLAG, &value);
stateChanged(pause ? "paused" : (isStream() ? "playing_stream" : "playing_file"));
}
void MpvPart::slotTogglePause() {
if (!m_mpv) return;
slotPause(!isPaused());
}
bool MpvPart::isPlaying()
{
if (!m_mpv) return false;
int result;
if (mpv_get_property(m_mpv, "core-idle", MPV_FORMAT_FLAG, &result) < 0) {
return false;
}
return !(bool)result;
}
bool MpvPart::isPaused() {
if (!m_mpv) return false;
int result;
if (mpv_get_property(m_mpv, "pause", MPV_FORMAT_FLAG, &result) < 0) {
return false;
}
return (bool)result;
}
bool MpvPart::isMute() {
if (!m_mpv) return false;
int result;
if (mpv_get_property(m_mpv, "ao-mute", MPV_FORMAT_FLAG, &result) < 0) {
return false;
}
return (bool)result;
}
bool MpvPart::isStream() {
TQString proto = m_mrl.kurl().protocol();
if (proto.startsWith("http") || proto.startsWith("rtsp")) {
return true;
}
return false;
}
bool MpvPart::isSeekable() {
if (!m_mpv) return false;
int result;
if (mpv_get_property(m_mpv, "seekable", MPV_FORMAT_FLAG, &result) < 0) {
return false;
}
return (bool)result;
}
void MpvPart::resetTime() {
m_playtime->setText("00:00:00");
m_position->setValue(0);
}
void MpvPart::slotStartSeeking() {
if (!isSeekable()) {
kdWarning() << "current file not seekable!" << endl;
emit setStatusBarText( i18n("Cannot seek current file!") );
return;
}
m_seeking = true;
slotPause(true);
}
void MpvPart::slotStopSeeking() {
if (!m_seeking) return;
slotSetPosition(m_seekpos);
slotPause(false);
m_seeking = false;
}
void MpvPart::slotSetSeekingPos(int percent) {
if (!m_seeking) return;
// Compute current position and update playtime indicator
TQTime duration = m_mrl.length();
uint secs = (duration.hour() * 60 * 60) +
(duration.minute() * 60) +
duration.second();
m_seekpos = secs * percent / 100;
TQTime pos; pos = pos.addSecs(m_seekpos);
updatePlaytime(pos);
}
uint MpvPart::volume() const {
int64_t value = 0;
mpv_get_property(m_mpv, "ao-volume", MPV_FORMAT_INT64, &value);
return value;
}
uint MpvPart::position() const {
return m_position->value();
}
void MpvPart::slotSetVolume(uint volume) {
if (!m_mpv) return;
int64_t value = (int64_t)volume;
mpv_set_property(m_mpv, "ao-volume", MPV_FORMAT_INT64, &value);
}
void MpvPart::slotSetPosition(uint secs) {
if (!m_mpv) return;
int64_t value = (int64_t)secs;
mpv_set_property(m_mpv, "time-pos", MPV_FORMAT_INT64, &value);
}
void MpvPart::slotPrevious() {
if ( m_current > 0 ) {
m_current--;
slotPlay();
}
else {
emit signalRequestPreviousTrack();
}
}
void MpvPart::slotNext() {
if ((m_playlist.count() > 0) && (m_current < m_playlist.count()-1)) {
m_current++;
slotPlay();
}
else {
emit signalRequestNextTrack();
}
}
void MpvPart::slotStop() {
if (!isPlaying()) {
return;
}
const char *stop_args[] = {"stop", nullptr};
mpv_command(m_mpv, stop_args);
closeURL();
stateChanged("not_playing");
}
void MpvPart::slotMute() {
int value = isMute() ? 0 : 1;
mpv_set_property(m_mpv, "ao-mute", MPV_FORMAT_FLAG, &value);
}
void MpvPart::slotReloadSubtitles() {
TQStringList subtitles = m_mrl.subtitleFiles();
int subId = m_mrl.currentSubtitle();
// Remove existing subtitles synchronously
const char *rm_args[] = {"sub-remove", nullptr};
mpv_command(m_mpv, rm_args);
if ((!subtitles.isEmpty()) && (subId > -1)) {
kdDebug() << "adding subtitle file " << subtitles[subId] << endl;
// Add requested subtitles
const char *add_args[] = {"sub-add", subtitles[subId].local8Bit(), nullptr};
mpv_command_async(m_mpv, 0, add_args);
}
}
void MpvPart::slotToggleRecording() {
if (m_recordAction->isChecked()) {
startRecording();
}
else {
stopRecording();
}
}
void MpvPart::startRecording() {
// Ensure we have an out file and start recording
if (m_recordFilePath.isEmpty()) {
slotSetRecordingFile();
}
if (m_recordFilePath.isEmpty()) {
m_recordAction->setChecked(false);
return;
}
mpv_set_property_string(m_mpv, "stream-record", m_recordFilePath.local8Bit());
m_recordAction->setChecked(true);
}
void MpvPart::stopRecording() {
mpv_set_property_string(m_mpv, "stream-record", "");
m_recordAction->setChecked(false);
}
void MpvPart::slotSetRecordingFile() {
if (m_recordAction->isChecked() && !m_recordFilePath.isEmpty()) {
if (!KMessageBox::warningContinueCancel(0,
i18n("Changing the recording file will stop the recording process."),
i18n("Recording in progress")) == KMessageBox::Continue)
{
return;
}
}
m_recordFilePath = KFileDialog::getSaveFileName(
TQString::null,
"*.mkv|" + i18n("Matroska file (*.mkv)") + "\n" +
"*.ogg|" + i18n("OGG media (*.ogg)") + "\n" +
"*.mp4|" + i18n("MPEG-4 video (*.mp4)") + "\n" +
"*.mpeg|" + i18n("MPEG video (*.mpeg)") + "\n" +
"*.avi|" + i18n("Microsoft AVI video (*.avi)") + "\n" +
"*.mp3|" + i18n("MPEG Layer 3 audio (*.mp3)") + "\n" +
"*.mp2|" + i18n("MPEG Layer 2 audio (*.mp2)") + "\n" +
"*.flac|" + i18n("FLAC audio (*.flac)") + "\n",
0, i18n("Select file name for saved recording"));
updateRecordFileLabel();
}
void MpvPart::updateRecordFileLabel() {
m_recordFile->setText(
m_recordFilePath.isEmpty()
? i18n("No output file")
: m_recordFilePath
);
}
void MpvPart::slotAddSubtitles() {
TQString openURL = m_mrl.url();
TQString subtitleURL = KFileDialog::getOpenURL(openURL,
i18n("*.smi *.srt *.sub *.txt *.ssa *.asc|Subtitle Files\n*.*|All Files"),
0, i18n("Select Subtitle File")).path();
kdDebug()<<"slotAddSubtitles: "<<subtitleURL<<endl;
kdDebug()<<" exists: "<<TQFile::exists(subtitleURL)<<endl;
if (subtitleURL.isEmpty() || !TQFile::exists(subtitleURL)) {
return;
}
m_mrl.addSubtitleFile(subtitleURL);
emit signalNewMeta(m_mrl);
}
void MpvPart::slotContextSetSubtitles(int choice) {
int subId = m_subs->itemParameter(choice);
if (subId == choice) {
// If itemParameter() returns the menu item id, then we know that
// the item has no item parameter, hence it is not an option that
// we handle through this slot.
return;
}
kdDebug() << "switching to subtitle: " << subId
<< " (" << m_subs->text(choice) << ")" << endl;
m_mrl.setCurrentSubtitle(subId);
emit signalNewMeta(m_mrl);
slotReloadSubtitles();
}
void MpvPart::showContextMenu() {
/*** Context menu ***/
m_context = new TQPopupMenu(0);
actionCollection()->action("player_play")->plug(m_context);
actionCollection()->action("player_pause")->plug(m_context);
actionCollection()->action("player_stop")->plug(m_context);
m_context->insertSeparator();
actionCollection()->action("player_previous")->plug(m_context);
actionCollection()->action("player_next")->plug(m_context);
m_context->insertSeparator();
/*** Subtitles submenu ***/
m_subs = new TQPopupMenu(0);
actionCollection()->action("subtitles_add")->plug(m_subs);
TQStringList subtitles = m_mrl.subtitleFiles();
if (m_mrl.isEmpty()) {
m_subs->setEnabled(false);
}
m_subs->insertSeparator();
int noSubsItem = m_subs->insertItem(i18n("None"));
m_subs->setItemParameter(noSubsItem, -1);
if (!subtitles.empty()) {
int index;
for (TQStringList::Iterator it = subtitles.begin(); it != subtitles.end(); ++it) {
int id = m_subs->insertItem((*it));
m_subs->setItemParameter(id, index);
// Comparing an index is cheaper than doing a string comparison
if (index == m_mrl.currentSubtitle()) {
m_subs->setItemChecked(id, true);
}
index++;
}
connect(m_subs, SIGNAL(activated(int)), this, SLOT(slotContextSetSubtitles(int)));
}
if (subtitles.empty() || m_mrl.currentSubtitle() < 0) {
m_subs->setItemChecked(noSubsItem, true);
}
m_context->insertItem(i18n("Sub&titles"), m_subs);
m_context->exec(TQCursor::pos());
hideContextMenu();
}
void MpvPart::hideContextMenu() {
if (m_context) {
delete m_context;
m_context = nullptr;
}
if (!m_subs) {
delete m_subs;
m_subs = nullptr;
}
}
#include "libmpv_part.moc"