1 /****************************************************************************************
2 * Copyright (c) 2008 Nikolaj Hald Nielsen <nhn@kde.org> *
3 * Copyright (c) 2010 Bart Cerneels <bart.cerneels@kde.org> *
4 * *
5 * This program is free software; you can redistribute it and/or modify it under *
6 * the terms of the GNU General Public License as published by the Free Software *
7 * Foundation; either version 2 of the License, or (at your option) any later *
8 * version. *
9 * *
10 * This program is distributed in the hope that it will be useful, but WITHOUT ANY *
11 * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A *
12 * PARTICULAR PURPOSE. See the GNU General Public License for more details. *
13 * *
14 * You should have received a copy of the GNU General Public License along with *
15 * this program. If not, see <http://www.gnu.org/licenses/>. *
16 ****************************************************************************************/
17
18 #define DEBUG_PREFIX "PlaylistBrowserView"
19
20 #include "PlaylistBrowserView.h"
21
22 #include "MainWindow.h"
23 #include "PaletteHandler.h"
24 #include "PopupDropperFactory.h"
25 #include "SvgHandler.h"
26 #include "amarokconfig.h"
27 #include "browsers/playlistbrowser/PlaylistBrowserModel.h"
28 #include "browsers/playlistbrowser/PlaylistsByProviderProxy.h"
29 #include "browsers/playlistbrowser/PlaylistsInFoldersProxy.h"
30 #include "context/ContextView.h"
31 #include "core/support/Debug.h"
32 #include "core-impl/playlists/types/file/PlaylistFileSupport.h"
33 #include "playlist/PlaylistModel.h"
34 #include "playlistmanager/PlaylistManager.h"
35 #include "widgets/PrettyTreeRoles.h"
36
37 #include <QCheckBox>
38 #include <QFileDialog>
39 #include <QKeyEvent>
40 #include <QLabel>
41 #include <QMenu>
42 #include <QMessageBox>
43 #include <QMouseEvent>
44
45 #include <KConfigGroup>
46
47 using namespace PlaylistBrowserNS;
48
PlaylistBrowserView(QAbstractItemModel * model,QWidget * parent)49 PlaylistBrowserNS::PlaylistBrowserView::PlaylistBrowserView( QAbstractItemModel *model,
50 QWidget *parent )
51 : Amarok::PrettyTreeView( parent )
52 , m_pd( 0 )
53 , m_ongoingDrag( false )
54 {
55 DEBUG_BLOCK
56 setModel( model );
57 setSelectionMode( QAbstractItemView::ExtendedSelection );
58 setSelectionBehavior( QAbstractItemView::SelectItems );
59 setDragDropMode( QAbstractItemView::DragDrop );
60 setAcceptDrops( true );
61 setEditTriggers( QAbstractItemView::EditKeyPressed );
62 setMouseTracking( true ); // needed for highlighting provider action icons
63
64 m_createEmptyPlaylistAction = new QAction( QIcon::fromTheme( QStringLiteral("media-track-add-amarok") ),
65 i18n( "Create an Empty Playlist" ), this );
66 connect( m_createEmptyPlaylistAction, &QAction::triggered, this, &PlaylistBrowserView::slotCreateEmptyPlaylist );
67
68 m_appendAction = new QAction( QIcon::fromTheme( QStringLiteral("media-track-add-amarok") ),
69 i18n( "&Add to Playlist" ), this );
70 m_appendAction->setProperty( "popupdropper_svg_id", "append" );
71 connect( m_appendAction, &QAction::triggered, this, &PlaylistBrowserView::slotAppend );
72
73 m_loadAction = new QAction( QIcon::fromTheme( QStringLiteral("folder-open") ), i18nc( "Replace the currently "
74 "loaded tracks with these", "&Replace Playlist" ), this );
75 m_loadAction->setProperty( "popupdropper_svg_id", "load" );
76 connect( m_loadAction, &QAction::triggered, this, &PlaylistBrowserView::slotLoad );
77
78 m_setNewAction = new QAction( QIcon::fromTheme( QStringLiteral("rating") ), i18nc( "toggle the \"new\" status "
79 " of this podcast episode", "&New" ), this );
80 m_setNewAction->setProperty( "popupdropper_svg_id", "new" );
81 m_setNewAction->setCheckable( true );
82 connect( m_setNewAction, &QAction::triggered, this, &PlaylistBrowserView::slotSetNew );
83
84 m_renamePlaylistAction = new QAction( QIcon::fromTheme( QStringLiteral("media-track-edit-amarok") ),
85 i18n( "&Rename..." ), this );
86 m_renamePlaylistAction->setProperty( "popupdropper_svg_id", "edit" );
87 // key shortcut is only for display purposes here, actual one is determined by View in Model/View classes
88 m_renamePlaylistAction->setShortcut( Qt::Key_F2 );
89 connect( m_renamePlaylistAction, &QAction::triggered, this, &PlaylistBrowserView::slotRename );
90
91 m_deletePlaylistAction = new QAction( QIcon::fromTheme( QStringLiteral("media-track-remove-amarok") ),
92 i18n( "&Delete..." ), this );
93 m_deletePlaylistAction->setProperty( "popupdropper_svg_id", "delete" );
94 // key shortcut is only for display purposes here, actual one is determined by View in Model/View classes
95 m_deletePlaylistAction->setShortcut( Qt::Key_Delete );
96 connect( m_deletePlaylistAction, &QAction::triggered, this, &PlaylistBrowserView::slotDelete );
97
98 m_removeTracksAction = new QAction( QIcon::fromTheme( QStringLiteral("media-track-remove-amarok") ),
99 QStringLiteral( "<placeholder>" ), this );
100 m_removeTracksAction->setProperty( "popupdropper_svg_id", "delete" );
101 // key shortcut is only for display purposes here, actual one is determined by View in Model/View classes
102 m_removeTracksAction->setShortcut( Qt::Key_Delete );
103 connect( m_removeTracksAction, &QAction::triggered, this, &PlaylistBrowserView::slotRemoveTracks );
104
105 m_exportAction = new QAction( QIcon::fromTheme( QStringLiteral("document-export-amarok") ),
106 i18n( "&Export As..." ), this );
107 connect( m_exportAction, &QAction::triggered, this, &PlaylistBrowserView::slotExport );
108
109 m_separatorAction = new QAction( this );
110 m_separatorAction->setSeparator( true );
111 }
112
113 void
setModel(QAbstractItemModel * model)114 PlaylistBrowserNS::PlaylistBrowserView::setModel( QAbstractItemModel *model )
115 {
116 if( this->model() )
117 disconnect( this->model(), 0, this, 0 );
118 Amarok::PrettyTreeView::setModel( model );
119
120 connect( this->model(), SIGNAL(renameIndex(QModelIndex)), SLOT(edit(QModelIndex)) );
121 }
122
123 void
mouseReleaseEvent(QMouseEvent * event)124 PlaylistBrowserNS::PlaylistBrowserView::mouseReleaseEvent( QMouseEvent *event )
125 {
126 if( m_pd )
127 {
128 connect( m_pd, &PopupDropper::fadeHideFinished, m_pd, &QObject::deleteLater );
129 m_pd->hide();
130 m_pd = 0;
131 }
132
133 QModelIndex index = indexAt( event->pos() );
134 if( !index.isValid() )
135 {
136 PrettyTreeView::mouseReleaseEvent( event );
137 return;
138 }
139
140 if( event->button() == Qt::MidButton )
141 {
142 insertIntoPlaylist( index, Playlist::OnMiddleClickOnSelectedItems );
143 event->accept();
144 return;
145 }
146
147 PrettyTreeView::mouseReleaseEvent( event );
148 }
149
startDrag(Qt::DropActions supportedActions)150 void PlaylistBrowserNS::PlaylistBrowserView::startDrag( Qt::DropActions supportedActions )
151 {
152 // Waah? when a parent item is dragged, startDrag is called a bunch of times
153 if( m_ongoingDrag )
154 return;
155 m_ongoingDrag = true;
156
157 if( !m_pd )
158 m_pd = The::popupDropperFactory()->createPopupDropper( Context::ContextView::self() );
159
160 if( m_pd && m_pd->isHidden() )
161 {
162 QActionList actions = actionsFor( selectedIndexes() );
163 foreach( QAction *action, actions )
164 m_pd->addItem( The::popupDropperFactory()->createItem( action ) );
165
166 m_pd->show();
167 }
168
169 QTreeView::startDrag( supportedActions );
170
171 // We keep the items that the actions need to be applied to.
172 // Clear the data from all actions now that the PUD has executed.
173 resetActionTargets();
174
175 if( m_pd )
176 {
177 connect( m_pd, &PopupDropper::fadeHideFinished, m_pd, &PopupDropper::clear );
178 m_pd->hide();
179 }
180 m_ongoingDrag = false;
181 }
182
183 void
keyPressEvent(QKeyEvent * event)184 PlaylistBrowserNS::PlaylistBrowserView::keyPressEvent( QKeyEvent *event )
185 {
186 QModelIndexList indices = selectedIndexes();
187 // mind bug 305203
188 if( indices.isEmpty() || state() != QAbstractItemView::NoState )
189 {
190 Amarok::PrettyTreeView::keyPressEvent( event );
191 return;
192 }
193
194 switch( event->key() )
195 {
196 //activated() only works for current index, not all selected
197 case Qt::Key_Enter:
198 case Qt::Key_Return:
199 insertIntoPlaylist( indices, Playlist::OnReturnPressedOnSelectedItems );
200 return;
201 case Qt::Key_Delete:
202 {
203 QActionList actions = actionsFor( indices ); // sets action targets
204 if( actions.contains( m_removeTracksAction ) )
205 m_removeTracksAction->trigger();
206 else if( actions.contains( m_deletePlaylistAction ) )
207 m_deletePlaylistAction->trigger();
208 resetActionTargets();
209 return;
210 }
211 default:
212 break;
213 }
214 Amarok::PrettyTreeView::keyPressEvent( event );
215 }
216
217 void
mouseDoubleClickEvent(QMouseEvent * event)218 PlaylistBrowserNS::PlaylistBrowserView::mouseDoubleClickEvent( QMouseEvent *event )
219 {
220 if( event->button() == Qt::MidButton )
221 {
222 event->accept();
223 return;
224 }
225
226 QModelIndex index = indexAt( event->pos() );
227 if( !index.isValid() )
228 {
229 event->accept();
230 return;
231 }
232
233 // code copied in CollectionTreeView::mouseDoubleClickEvent(), keep in sync
234 // mind bug 279513
235 bool isExpandable = model()->hasChildren( index );
236 bool wouldExpand = !visualRect( index ).contains( event->pos() ) || // clicked outside item, perhaps on expander icon
237 ( isExpandable && !style()->styleHint( QStyle::SH_ItemView_ActivateItemOnSingleClick, 0, this ) ); // we're in doubleClick
238 if( event->button() == Qt::LeftButton &&
239 event->modifiers() == Qt::NoModifier &&
240 !wouldExpand )
241 {
242 insertIntoPlaylist( index, Playlist::OnDoubleClickOnSelectedItems );
243 event->accept();
244 return;
245 }
246
247 PrettyTreeView::mouseDoubleClickEvent( event );
248 }
249
contextMenuEvent(QContextMenuEvent * event)250 void PlaylistBrowserNS::PlaylistBrowserView::contextMenuEvent( QContextMenuEvent *event )
251 {
252 QModelIndex clickedIdx = indexAt( event->pos() );
253
254 QModelIndexList indices;
255 if( clickedIdx.isValid() && selectedIndexes().contains( clickedIdx ) )
256 indices << selectedIndexes();
257 else if( clickedIdx.isValid() )
258 indices << clickedIdx;
259
260 QActionList actions = actionsFor( indices );
261 if( actions.isEmpty() )
262 {
263 resetActionTargets();
264 return;
265 }
266
267 QMenu menu;
268 foreach( QAction *action, actions )
269 menu.addAction( action );
270 menu.exec( mapToGlobal( event->pos() ) );
271
272 // We keep the items that the action need to be applied to.
273 // Clear the data from all actions now that the context menu has executed.
274 resetActionTargets();
275 }
276
277 QList<QAction *>
actionsFor(const QModelIndexList & indexes)278 PlaylistBrowserNS::PlaylistBrowserView::actionsFor( const QModelIndexList &indexes )
279 {
280 resetActionTargets();
281 if( indexes.isEmpty() )
282 return QActionList();
283
284 using namespace Playlists;
285 QSet<PlaylistProvider *> providers, writableProviders;
286 QActionList actions;
287 QModelIndexList newPodcastEpisodes, oldPodcastEpisodes;
288 foreach( const QModelIndex &idx, indexes )
289 {
290 // direct provider actions:
291 actions << idx.data( PrettyTreeRoles::DecoratorRole ).value<QActionList>();
292
293 PlaylistProvider *provider = idx.data( PlaylistBrowserModel::ProviderRole ).value<PlaylistProvider *>();
294 if( provider )
295 providers << provider;
296 bool isWritable = provider ? provider->isWritable() : false;
297 if( isWritable )
298 writableProviders |= provider;
299 Meta::TrackPtr track = idx.data( PlaylistBrowserModel::TrackRole ).value<Meta::TrackPtr>();
300 PlaylistPtr playlist = idx.data( PlaylistBrowserModel::PlaylistRole ).value<PlaylistPtr>();
301 if( !track && playlist ) // a playlist (must check it is not a track)
302 {
303 m_actionPlaylists << playlist;
304 if( isWritable )
305 m_writableActionPlaylists << playlist;
306 }
307 if( track )
308 {
309 m_actionTracks.insert( playlist, idx.row() );
310 if( isWritable )
311 m_writableActionTracks.insert( playlist, idx.row() );
312 }
313
314 QVariant episodeIsNew = idx.data( PlaylistBrowserModel::EpisodeIsNewRole );
315 if( episodeIsNew.type() == QVariant::Bool )
316 {
317 if( episodeIsNew.toBool() )
318 newPodcastEpisodes << idx;
319 else
320 oldPodcastEpisodes << idx;
321 }
322 }
323 // all actions taking provider have only sense with one provider
324 if( writableProviders.count() == 1 )
325 m_writableActionProvider = writableProviders.toList().first();
326
327 // process per-provider actions
328 foreach( PlaylistProvider *provider, providers )
329 {
330 // prepare arguments and get relevant actions
331 PlaylistList providerPlaylists;
332 foreach( const PlaylistPtr &playlist, m_actionPlaylists )
333 {
334 if( playlist->provider() == provider )
335 providerPlaylists << playlist;
336 }
337 actions << provider->playlistActions( providerPlaylists );
338
339 QMultiHash<PlaylistPtr, int> playlistTracks;
340 QHashIterator<PlaylistPtr, int> it( m_actionTracks );
341 while( it.hasNext() )
342 {
343 it.next();
344 if( it.key()->provider() == provider )
345 playlistTracks.insert( it.key(), it.value() );
346 }
347 actions << provider->trackActions( playlistTracks );
348 }
349
350 // separate model actions from standard actions we provide (at the top)
351 QActionList standardActions;
352 if( m_actionPlaylists.isEmpty() && m_actionTracks.isEmpty() && m_writableActionProvider )
353 standardActions << m_createEmptyPlaylistAction;
354 if( !m_actionPlaylists.isEmpty() || !m_actionTracks.isEmpty() )
355 standardActions << m_appendAction << m_loadAction;
356 if( !newPodcastEpisodes.isEmpty() || !oldPodcastEpisodes.isEmpty() )
357 {
358 m_setNewAction->setChecked( oldPodcastEpisodes.isEmpty() );
359 m_setNewAction->setData( QVariant::fromValue( newPodcastEpisodes + oldPodcastEpisodes ) );
360 standardActions << m_setNewAction;
361 }
362 if( m_writableActionPlaylists.count() == 1 && m_actionTracks.isEmpty() )
363 standardActions << m_renamePlaylistAction;
364 if( !m_writableActionPlaylists.isEmpty() && m_actionTracks.isEmpty() )
365 standardActions << m_deletePlaylistAction;
366 if( m_actionPlaylists.isEmpty() && !m_writableActionTracks.isEmpty() )
367 {
368 const int actionTrackCount = m_writableActionTracks.count();
369 const int playlistCount = m_writableActionTracks.uniqueKeys().count();
370 if( playlistCount > 1 )
371 m_removeTracksAction->setText( i18nc( "%1: number of tracks. %2: number of playlists",
372 "Remove %1 From %2", i18ncp ("First part of 'Remove %1 From %2'", "a Track",
373 "%1 Tracks", actionTrackCount), i18ncp ("Second part of 'Remove %1 From %2'", "1 Playlist",
374 "%1 Playlists", playlistCount ) ) );
375 else
376 m_removeTracksAction->setText( i18ncp( "%2 is saved playlist name",
377 "Remove a Track From %2", "Remove %1 Tracks From %2", actionTrackCount,
378 m_writableActionTracks.uniqueKeys().first()->prettyName() ) );
379 standardActions << m_removeTracksAction;
380 }
381 if( m_actionPlaylists.count() == 1 && m_actionTracks.isEmpty() )
382 standardActions << m_exportAction;
383 standardActions << m_separatorAction;
384
385 return standardActions + actions;
386 }
387
388 void
resetActionTargets()389 PlaylistBrowserView::resetActionTargets()
390 {
391 m_writableActionProvider = 0;
392 m_actionPlaylists.clear();
393 m_writableActionPlaylists.clear();
394 m_actionTracks.clear();
395 m_writableActionTracks.clear();
396 }
397
398 void
currentChanged(const QModelIndex & current,const QModelIndex & previous)399 PlaylistBrowserNS::PlaylistBrowserView::currentChanged( const QModelIndex ¤t,
400 const QModelIndex &previous )
401 {
402 Q_UNUSED( previous )
403 Q_EMIT currentItemChanged( current );
404 Amarok::PrettyTreeView::currentChanged( current, previous );
405 }
406
407 void
slotCreateEmptyPlaylist()408 PlaylistBrowserView::slotCreateEmptyPlaylist()
409 {
410 // m_actionProvider may be null, which is fine
411 The::playlistManager()->save( Meta::TrackList(), Amarok::generatePlaylistName(
412 Meta::TrackList() ), m_writableActionProvider );
413 }
414
415 void
slotAppend()416 PlaylistBrowserView::slotAppend()
417 {
418 insertIntoPlaylist( Playlist::OnAppendToPlaylistAction );
419 }
420
421 void
slotLoad()422 PlaylistBrowserView::slotLoad()
423 {
424 insertIntoPlaylist( Playlist::OnReplacePlaylistAction );
425 }
426
427 void
slotSetNew(bool newState)428 PlaylistBrowserView::slotSetNew( bool newState )
429 {
430 QModelIndexList indices = m_setNewAction->data().value<QModelIndexList>();
431 foreach( const QModelIndex &idx, indices )
432 model()->setData( idx, newState, PlaylistBrowserModel::EpisodeIsNewRole );
433 }
434
435 void
slotRename()436 PlaylistBrowserView::slotRename()
437 {
438 if( m_writableActionPlaylists.count() != 1 )
439 {
440 warning() << __PRETTY_FUNCTION__ << "m_writableActionPlaylists.count() is not 1";
441 return;
442 }
443 Playlists::PlaylistPtr playlist = m_writableActionPlaylists.at( 0 );
444
445 // TODO: this makes a rather complicated round-trip and ends up in edit(QModelIndex)
446 // here -- simplify that
447 The::playlistManager()->rename( playlist );
448 }
449
450 void
slotDelete()451 PlaylistBrowserView::slotDelete()
452 {
453 if( m_writableActionPlaylists.isEmpty() )
454 return;
455
456 using namespace Playlists;
457 QHash<PlaylistProvider *, PlaylistList> providerPlaylists;
458 foreach( const PlaylistPtr &playlist, m_writableActionPlaylists )
459 {
460 if( playlist->provider() )
461 providerPlaylists[ playlist->provider() ] << playlist;
462 }
463 QStringList providerNames;
464 foreach( const PlaylistProvider *provider, providerPlaylists.keys() )
465 providerNames << provider->prettyName();
466
467 auto button = QMessageBox::question( The::mainWindow(),
468 i18n( "Confirm Playlist Deletion" ),
469 i18nc( "%1 is playlist provider pretty name",
470 "Delete playlist from %1.", providerNames.join( QStringLiteral(", ") ) ),
471 QMessageBox::Yes | QMessageBox::No,
472 QMessageBox::Yes );
473
474 if( button == QMessageBox::Yes )
475 {
476 foreach( PlaylistProvider *provider, providerPlaylists.keys() )
477 provider->deletePlaylists( providerPlaylists.value( provider ) );
478 }
479 }
480
481 void
slotRemoveTracks()482 PlaylistBrowserView::slotRemoveTracks()
483 {
484 foreach( Playlists::PlaylistPtr playlist, m_writableActionTracks.uniqueKeys() )
485 {
486 QList<int> trackIndices = m_writableActionTracks.values( playlist );
487 qSort( trackIndices );
488 int removed = 0;
489 foreach( int trackIndex, trackIndices )
490 {
491 playlist->removeTrack( trackIndex - removed /* account for already removed */ );
492 removed++;
493 }
494 }
495 }
496
497 void
slotExport()498 PlaylistBrowserView::slotExport()
499 {
500 if( m_actionPlaylists.count() != 1 )
501 {
502 warning() << __PRETTY_FUNCTION__ << "m_actionPlaylists.count() is not 1";
503 return;
504 }
505 Playlists::PlaylistPtr playlist = m_actionPlaylists.at( 0 );
506
507 // --- display save location dialog
508 // compare with MainWindow::exportPlaylist
509 // TODO: have this code only once
510 QFileDialog fileDialog;
511 fileDialog.restoreState( Amarok::config( QStringLiteral("playlist-export-dialog") ).readEntry( "state", QByteArray() ) );
512
513 // FIXME: Make checkbox visible in dialog
514 QCheckBox *saveRelativeCheck = new QCheckBox( i18n("Use relative path for &saving"), &fileDialog );
515 saveRelativeCheck->setChecked( AmarokConfig::relativePlaylist() );
516
517 QStringList supportedMimeTypes;
518
519 supportedMimeTypes << QStringLiteral("video/x-ms-asf"); //ASX
520 supportedMimeTypes << QStringLiteral("audio/x-mpegurl"); //M3U
521 supportedMimeTypes << QStringLiteral("audio/x-scpls"); //PLS
522 supportedMimeTypes << QStringLiteral("application/xspf+xml"); //XSPF
523
524 fileDialog.setMimeTypeFilters( supportedMimeTypes );
525 fileDialog.setAcceptMode( QFileDialog::AcceptSave );
526 fileDialog.setFileMode( QFileDialog::AnyFile );
527 fileDialog.setWindowTitle( i18n("Save As") );
528 fileDialog.setObjectName( QStringLiteral("PlaylistExport") );
529
530 int result = fileDialog.exec();
531 QString playlistPath = fileDialog.selectedFiles().value( 0 );
532 if( result == QDialog::Accepted && !playlistPath.isEmpty() )
533 Playlists::exportPlaylistFile( playlist->tracks(), QUrl::fromLocalFile( playlistPath ) );
534
535 Amarok::config( QStringLiteral("playlist-export-dialog") ).writeEntry( "state", fileDialog.saveState() );
536 }
537
538 void
insertIntoPlaylist(const QModelIndex & index,Playlist::AddOptions options)539 PlaylistBrowserView::insertIntoPlaylist( const QModelIndex &index, Playlist::AddOptions options )
540 {
541 insertIntoPlaylist( QModelIndexList() << index, options );
542 }
543
544 void
insertIntoPlaylist(const QModelIndexList & list,Playlist::AddOptions options)545 PlaylistBrowserView::insertIntoPlaylist( const QModelIndexList &list, Playlist::AddOptions options )
546 {
547 actionsFor( list ); // sets action targets
548 insertIntoPlaylist( options );
549 resetActionTargets();
550 }
551
552 void
insertIntoPlaylist(Playlist::AddOptions options)553 PlaylistBrowserView::insertIntoPlaylist( Playlist::AddOptions options )
554 {
555 Meta::TrackList tracks;
556
557 // add tracks for fully-selected playlists:
558 foreach( Playlists::PlaylistPtr playlist, m_actionPlaylists )
559 {
560 tracks << playlist->tracks();
561 }
562
563 // filter-out tracks from playlists that are selected, add lone tracks:
564 foreach( Playlists::PlaylistPtr playlist, m_actionTracks.uniqueKeys() )
565 {
566 if( m_actionPlaylists.contains( playlist ) )
567 continue;
568
569 Meta::TrackList playlistTracks = playlist->tracks();
570 QList<int> positions = m_actionTracks.values( playlist );
571 qSort( positions );
572 foreach( int position, positions )
573 {
574 if( position >= 0 && position < playlistTracks.count() )
575 tracks << playlistTracks.at( position );
576 }
577 }
578
579 if( !tracks.isEmpty() )
580 The::playlistController()->insertOptioned( tracks, options );
581 }
582