playermanager.cpp 19.7 KB
Newer Older
1 2 3 4
/***************************************************************************
    begin                : Sat Feb 14 2004
    copyright            : (C) 2004 by Scott Wheeler
    email                : wheeler@kde.org
5 6 7 8

    copyright            : (C) 2007 by Matthias Kretz
    email                : kretz@kde.org

9
    copyright            : (C) 2008, 2009 by Michael Pyne
10
    email                : michael.pyne@kdemail.net
11 12 13 14 15 16 17 18 19 20 21
***************************************************************************/

/***************************************************************************
 *                                                                         *
 *   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.                                   *
 *                                                                         *
 ***************************************************************************/

22 23
#include "playermanager.h"

24
#include <kdebug.h>
Matthias Kretz's avatar
Matthias Kretz committed
25
#include <kmessagebox.h>
26
#include <klocale.h>
27 28 29
#include <kactioncollection.h>
#include <kselectaction.h>
#include <ktoggleaction.h>
Andreas Pakulat's avatar
Andreas Pakulat committed
30
#include <kurl.h>
31

32 33 34
#include <Phonon/AudioOutput>
#include <Phonon/MediaObject>
#include <Phonon/VolumeFaderEffect>
35

Laurent Montel's avatar
Laurent Montel committed
36
#include <QPixmap>
37 38
#include <QTimer>

39 40
#include <math.h>

41
#include "playlistinterface.h"
42
#include "playeradaptor.h"
43
#include "slideraction.h"
44
#include "statuslabel.h"
45
#include "actioncollection.h"
46
#include "collectionlist.h"
Michael Pyne's avatar
Michael Pyne committed
47
#include "coverinfo.h"
48
#include "tag.h"
49
#include "scrobbler.h"
50
#include "juk.h"
51 52

using namespace ActionCollection;
53

Stephan Kulow's avatar
Stephan Kulow committed
54
enum PlayerManagerStatus { StatusStopped = -1, StatusPaused = 1, StatusPlaying = 2 };
55

56 57 58 59 60
////////////////////////////////////////////////////////////////////////////////
// protected members
////////////////////////////////////////////////////////////////////////////////

PlayerManager::PlayerManager() :
61
    QObject(),
62
    m_playlistInterface(0),
63
    m_statusLabel(0),
64
    m_setup(false),
65
    m_crossfadeTracks(true),
66
    m_curOutputPath(0)
67
{
Michael Pyne's avatar
Michael Pyne committed
68 69 70 71 72
// This class is the first thing constructed during program startup, and
// therefore has no access to the widgets needed by the setup() method.
// Since the setup() method will be called indirectly by the player() method
// later, just disable it here. -- mpyne
//    setup();
Laurent Montel's avatar
Laurent Montel committed
73
    new PlayerAdaptor( this );
74 75 76 77
    
    
    m_scrobbler = new Scrobbler(this);
    connect(this, SIGNAL(signalItemChanged(FileHandle)), m_scrobbler, SLOT(nowPlaying(FileHandle)));
78 79 80 81
}

PlayerManager::~PlayerManager()
{
82

83 84 85 86 87 88
}

////////////////////////////////////////////////////////////////////////////////
// public members
////////////////////////////////////////////////////////////////////////////////

89 90
bool PlayerManager::playing() const
{
91
    if(!m_setup)
92 93
        return false;

94 95
    Phonon::State state = m_media[m_curOutputPath]->state();
    return (state == Phonon::PlayingState || state == Phonon::BufferingState);
96 97 98 99
}

bool PlayerManager::paused() const
{
100
    if(!m_setup)
101 102
        return false;

103
    return m_media[m_curOutputPath]->state() == Phonon::PausedState;
104 105
}

106 107 108 109 110 111 112 113
bool PlayerManager::muted() const
{
    if(!m_setup)
        return false;

    return m_output[m_curOutputPath]->isMuted();
}

114 115
float PlayerManager::volume() const
{
116
    if(!m_setup)
117
        return 1.0;
118

119
    return m_output[m_curOutputPath]->volume();
120 121
}

122 123
int PlayerManager::status() const
{
124
    if(!m_setup)
125
        return StatusStopped;
126

127
    if(paused())
128
        return StatusPaused;
129

130
    if(playing())
131
        return StatusPlaying;
132

133 134 135
    return 0;
}

Scott Wheeler's avatar
Scott Wheeler committed
136
int PlayerManager::totalTime() const
137 138 139 140 141 142 143 144 145 146
{
    return totalTimeMSecs() / 1000;
}

int PlayerManager::currentTime() const
{
    return currentTimeMSecs() / 1000;
}

int PlayerManager::totalTimeMSecs() const
147
{
148
    if(!m_setup)
149 150
        return 0;

151
    return m_media[m_curOutputPath]->totalTime();
152 153
}

154
int PlayerManager::currentTimeMSecs() const
155
{
156
    if(!m_setup)
157 158
        return 0;

159
    return m_media[m_curOutputPath]->currentTime();
160 161
}

162 163 164 165 166 167 168 169
bool PlayerManager::seekable() const
{
    if(!m_setup)
        return false;

    return m_media[m_curOutputPath]->isSeekable();
}

170 171 172 173 174 175 176
QStringList PlayerManager::trackProperties()
{
    return FileHandle::properties();
}

QString PlayerManager::trackProperty(const QString &property) const
{
Michael Pyne's avatar
Michael Pyne committed
177
    if(!playing() && !paused())
178
        return QString();
179 180 181 182

    return m_file.property(property);
}

Michael Pyne's avatar
Michael Pyne committed
183 184 185 186 187
QPixmap PlayerManager::trackCover(const QString &size) const
{
    if(!playing() && !paused())
        return QPixmap();

188
    if(size.toLower() == "small")
Michael Pyne's avatar
Michael Pyne committed
189
        return m_file.coverInfo()->pixmap(CoverInfo::Thumbnail);
190
    if(size.toLower() == "large")
Michael Pyne's avatar
Michael Pyne committed
191 192 193 194 195
        return m_file.coverInfo()->pixmap(CoverInfo::FullSize);

    return QPixmap();
}

196 197 198 199 200
FileHandle PlayerManager::playingFile() const
{
    return m_file;
}

201 202
QString PlayerManager::playingString() const
{
203
    if(!playing() || m_file.isNull())
204
        return QString();
205

206
    return m_file.tag()->playingString();
207 208
}

209
void PlayerManager::setPlaylistInterface(PlaylistInterface *interface)
210 211 212 213
{
    m_playlistInterface = interface;
}

214 215 216 217 218
void PlayerManager::setStatusLabel(StatusLabel *label)
{
    m_statusLabel = label;
}

219 220 221 222
////////////////////////////////////////////////////////////////////////////////
// public slots
////////////////////////////////////////////////////////////////////////////////

223
void PlayerManager::play(const FileHandle &file)
224
{
225
    if(!m_setup)
226 227
        setup();

228
    if(!m_media[0] || !m_media[1] || !m_playlistInterface)
229 230
        return;

231 232 233 234
    stopCrossfade();

    // The "currently playing" media object.
    Phonon::MediaObject *mediaObject = m_media[m_curOutputPath];
235
    
236
    if(file.isNull()) {
237
        if(paused())
238
            mediaObject->play();
239
        else if(playing()) {
240
            mediaObject->seek(0);
241
            emit seeked(0);
242
        }
243
        else {
244
            m_playlistInterface->playNext();
245
            m_file = m_playlistInterface->currentFile();
246

247
            if(!m_file.isNull())
248
            {
249 250
                mediaObject->setCurrentSource(KUrl::fromPath(m_file.absFilePath()));
                mediaObject->play();
251 252

                emit signalItemChanged(m_file);
253
            }
254
        }
255
    }
256
    else {
257
        mediaObject->setCurrentSource(KUrl::fromPath(file.absFilePath()));
258
        mediaObject->play();
259 260 261 262 263

        if(m_file != file)
            emit signalItemChanged(file);

        m_file = file;
264
    }
265

266 267
    // Our state changed handler will perform the follow up actions necessary
    // once we actually start playing.
268 269
}

270 271 272 273 274 275 276 277 278
void PlayerManager::play(const QString &file)
{
    CollectionListItem *item = CollectionList::instance()->lookup(file);
    if(item) {
        Playlist::setPlaying(item);
        play(item->file());
    }
}

Scott Wheeler's avatar
Scott Wheeler committed
279 280 281 282 283
void PlayerManager::play()
{
    play(FileHandle::null());
}

284 285
void PlayerManager::pause()
{
286
    if(!m_setup)
287 288
        return;

289
    if(paused()) {
290 291 292 293
        play();
        return;
    }

294
    action("pause")->setEnabled(false);
295

296
    m_media[m_curOutputPath]->pause();
297 298

    emit signalPause();
299 300 301 302
}

void PlayerManager::stop()
{
303
    if(!m_setup || !m_playlistInterface)
304 305
        return;

306 307 308 309
    action("pause")->setEnabled(false);
    action("stop")->setEnabled(false);
    action("back")->setEnabled(false);
    action("forward")->setEnabled(false);
310
    action("forwardAlbum")->setEnabled(false);
311

312 313 314 315
    // Fading out playback is for chumps.
    stopCrossfade();
    m_media[0]->stop();
    m_media[1]->stop();
316 317 318 319 320

    if(!m_file.isNull()) {
        m_file = FileHandle::null();
        emit signalItemChanged(m_file);
    }
321 322
}

Laurent Montel's avatar
Laurent Montel committed
323
void PlayerManager::setVolume(float volume)
324
{
325
    if(!m_setup)
326
        setup();
327

328 329
    m_output[0]->setVolume(volume);
    m_output[1]->setVolume(volume);
330 331
}

332
void PlayerManager::seek(int seekTime)
333
{
334
    if(!m_setup || m_media[m_curOutputPath]->currentTime() == seekTime)
335
        return;
336

337 338
    kDebug() << "Stopping crossfade to seek from" << m_media[m_curOutputPath]->currentTime()
             << "to" << seekTime;
339 340
    stopCrossfade();
    m_media[m_curOutputPath]->seek(seekTime);
341
    emit seeked(seekTime);
342 343 344 345
}

void PlayerManager::seekForward()
{
346 347 348
    Phonon::MediaObject *mediaObject = m_media[m_curOutputPath];
    const qint64 total = mediaObject->totalTime();
    const qint64 newtime = mediaObject->currentTime() + total / 100;
349
    const qint64 seekTo = qMin(total, newtime);
350 351

    stopCrossfade();
352 353
    mediaObject->seek(seekTo);
    emit seeked(seekTo);
354 355 356 357
}

void PlayerManager::seekBack()
{
358 359 360
    Phonon::MediaObject *mediaObject = m_media[m_curOutputPath];
    const qint64 total = mediaObject->totalTime();
    const qint64 newtime = mediaObject->currentTime() - total / 100;
361
    const qint64 seekTo = qMax(qint64(0), newtime);
362 363

    stopCrossfade();
364 365
    mediaObject->seek(seekTo);
    emit seeked(seekTo);
366 367
}

368 369
void PlayerManager::playPause()
{
David Faure's avatar
David Faure committed
370
    playing() ? action("pause")->trigger() : action("play")->trigger();
371 372
}

373 374
void PlayerManager::forward()
{
375 376
    m_playlistInterface->playNext();
    FileHandle file = m_playlistInterface->currentFile();
377

378 379 380 381 382 383 384 385
    if(!file.isNull())
        play(file);
    else
        stop();
}

void PlayerManager::back()
{
386 387
    m_playlistInterface->playPrevious();
    FileHandle file = m_playlistInterface->currentFile();
388

389 390 391 392 393 394
    if(!file.isNull())
        play(file);
    else
        stop();
}

395 396
void PlayerManager::volumeUp()
{
397
    if(!m_setup)
398
        return;
399

400
    setVolume(volume() + 0.04); // 4% up
401 402 403 404
}

void PlayerManager::volumeDown()
{
405
    if(!m_output)
406
        return;
407

408
    setVolume(volume() - 0.04); // 4% down
409 410
}

411
void PlayerManager::setMuted(bool m)
412
{
413
    if(!m_setup)
414 415
        return;

416 417 418 419 420 421 422 423 424 425 426
    m_output[m_curOutputPath]->setMuted(m);
}

bool PlayerManager::mute()
{
    if(!m_setup)
        return false;

    bool newState = !muted();
    setMuted(newState);
    return newState;
427 428
}

429 430 431
////////////////////////////////////////////////////////////////////////////////
// private slots
////////////////////////////////////////////////////////////////////////////////
432

433 434
void PlayerManager::slotNeedNextUrl()
{
435
    m_scrobbler->scrobble();
436
    if(m_file.isNull() || !m_crossfadeTracks)
Matthias Kretz's avatar
Matthias Kretz committed
437
        return;
438

439 440
    m_playlistInterface->playNext();
    FileHandle nextFile = m_playlistInterface->currentFile();
441 442

    if(!nextFile.isNull()) {
443
        m_file = nextFile;
444
        crossfadeToFile(m_file);
445 446 447
    }
}

448 449
void PlayerManager::slotFinished()
{
450 451 452 453
    // It is possible to end up in this function if a file simply fails to play or if the
    // user moves the slider all the way to the end, therefore see if we can keep playing
    // and if we can, do so.  Otherwise, stop.  Note that this slot should
    // only be called by the currently "main" output path (i.e. not from the
454 455 456 457 458 459
    // crossfading one).  However life isn't always so nice apparently, so do some
    // sanity-checking.

    Phonon::MediaObject *mediaObject = qobject_cast<Phonon::MediaObject *>(sender());
    if(mediaObject != m_media[m_curOutputPath])
        return;
460

461 462
    m_playlistInterface->playNext();
    m_file = m_playlistInterface->currentFile();
463

464 465 466 467
    if(m_file.isNull()) {
        stop();
    }
    else {
468
        emit signalItemChanged(m_file);
469 470 471
        m_media[m_curOutputPath]->setCurrentSource(m_file.absFilePath());
        m_media[m_curOutputPath]->play();
    }
472 473
}

474 475 476
void PlayerManager::slotLength(qint64 msec)
{
    m_statusLabel->setItemTotalTime(msec / 1000);
477
    emit totalTimeChanged(msec);
478 479
}

480
void PlayerManager::slotTick(qint64 msec)
481
{
482
    if(!m_setup || !m_playlistInterface)
483
        return;
484

485
    if(m_statusLabel)
486
        m_statusLabel->setItemCurrentTime(msec / 1000);
487 488

    emit tick(msec);
489 490
}

491
void PlayerManager::slotStateChanged(Phonon::State newstate, Phonon::State oldstate)
Matthias Kretz's avatar
Matthias Kretz committed
492
{
493 494 495 496 497 498
    // Use sender() since either media object may have sent the signal.
    Phonon::MediaObject *mediaObject = qobject_cast<Phonon::MediaObject *>(sender());
    if(!mediaObject)
        return;

    // Handle errors for either media object
499
    if(newstate == Phonon::ErrorState) {
500 501 502
        QString errorMessage =
            i18nc(
              "%1 will be the /path/to/file, %2 will be some string from Phonon describing the error",
503 504
              "JuK is unable to play the audio file<nl/><filename>%1</filename><nl/>"
                "for the following reason:<nl/><message>%2</message>",
505
              m_file.absFilePath(),
506
              mediaObject->errorString()
507 508
            );

509
        switch(mediaObject->errorType()) {
510 511 512
            case Phonon::NoError:
                kDebug() << "received a state change to ErrorState but errorType is NoError!?";
                break;
513

Matthias Kretz's avatar
Matthias Kretz committed
514 515
            case Phonon::NormalError:
                forward();
516
                KMessageBox::information(0, errorMessage);
Matthias Kretz's avatar
Matthias Kretz committed
517
                break;
518

Matthias Kretz's avatar
Matthias Kretz committed
519 520
            case Phonon::FatalError:
                stop();
521
                KMessageBox::sorry(0, errorMessage);
Matthias Kretz's avatar
Matthias Kretz committed
522 523 524
                break;
        }
    }
525

526 527 528 529 530
    // Now bail out if we're not dealing with the currently playing media
    // object.

    if(mediaObject != m_media[m_curOutputPath])
        return;
531

532 533 534 535 536 537
    // Handle state changes for the playing media object.
    if(newstate == Phonon::StoppedState && oldstate != Phonon::LoadingState) {
        // If this occurs it should be due to a transitory shift (i.e. playing a different
        // song when one is playing now), since it didn't occur in the error handler.  Just
        // in case we really did abruptly stop, handle that case in a couple of seconds.
        QTimer::singleShot(2000, this, SLOT(slotUpdateGuiIfStopped()));
538 539 540

        JuK::JuKInstance()->setWindowTitle(i18n("JuK"));
        
Michael Pyne's avatar
Michael Pyne committed
541
        emit signalStop();
542
    }
543 544 545 546 547 548 549 550
    else if(newstate == Phonon::PlayingState) {
        action("pause")->setEnabled(true);
        action("stop")->setEnabled(true);
        action("forward")->setEnabled(true);
        if(action<KToggleAction>("albumRandomPlay")->isChecked())
            action("forwardAlbum")->setEnabled(true);
        action("back")->setEnabled(true);

551 552 553 554 555 556 557
                
        JuK::JuKInstance()->setWindowTitle(i18nc(
            "%1 is the artist and %2 is the title of the currently playing track.", 
            "%1 - %2 :: JuK", 
            m_file.tag()->artist(), 
            m_file.tag()->title()));
        
558 559
        emit signalPlay();
    }
560 561
}

562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599
void PlayerManager::slotSeekableChanged(bool isSeekable)
{
    // Use sender() since either media object may have sent the signal.
    Phonon::MediaObject *mediaObject = qobject_cast<Phonon::MediaObject *>(sender());
    if(!mediaObject)
        return;
    if(mediaObject != m_media[m_curOutputPath])
        return;

    emit seekableChanged(isSeekable);
}

void PlayerManager::slotMutedChanged(bool muted)
{
    // Use sender() since either output object may have sent the signal.
    Phonon::AudioOutput *output = qobject_cast<Phonon::AudioOutput *>(sender());
    if(!output)
        return;

    if(output != m_output[m_curOutputPath])
        return;

    emit mutedChanged(muted);
}

void PlayerManager::slotVolumeChanged(qreal volume)
{
    // Use sender() since either output object may have sent the signal.
    Phonon::AudioOutput *output = qobject_cast<Phonon::AudioOutput *>(sender());
    if(!output)
        return;

    if(output != m_output[m_curOutputPath])
        return;

    emit volumeChanged(volume);
}

600 601 602 603 604 605 606 607
////////////////////////////////////////////////////////////////////////////////
// private members
////////////////////////////////////////////////////////////////////////////////

void PlayerManager::setup()
{
    // All of the actions required by this class should be listed here.

608 609 610
    if(!action("pause") ||
       !action("stop") ||
       !action("back") ||
611
       !action("forwardAlbum") ||
612 613
       !action("forward") ||
       !action("trackPositionAction"))
614
    {
615
        kWarning() << "Could not find all of the required actions.";
616 617 618
        return;
    }

619 620 621 622
    if(m_setup)
        return;
    m_setup = true;

623 624 625 626 627 628
    // We use two audio paths at all times to make cross fading easier (and to also easily
    // support not using cross fading with the same code).  The currently playing audio
    // path is controlled using m_curOutputPath.

    for(int i = 0; i < 2; ++i) {
        m_output[i] = new Phonon::AudioOutput(Phonon::MusicCategory, this);
629 630
        connect(m_output[i], SIGNAL(mutedChanged(bool)), SLOT(slotMutedChanged(bool)));
        connect(m_output[i], SIGNAL(volumeChanged(qreal)), SLOT(slotVolumeChanged(qreal)));
631

632 633 634
        m_media[i] = new Phonon::MediaObject(this);
        m_audioPath[i] = Phonon::createPath(m_media[i], m_output[i]);
        m_media[i]->setTickInterval(200);
635
        m_media[i]->setPrefinishMark(2000);
636

637 638 639 640 641
        // Pre-cache a volume fader object
        m_fader[i] = new Phonon::VolumeFaderEffect(m_media[i]);
        m_audioPath[i].insertEffect(m_fader[i]);
        m_fader[i]->setVolume(1.0f);

Laurent Montel's avatar
Laurent Montel committed
642
        connect(m_media[i], SIGNAL(stateChanged(Phonon::State,Phonon::State)), SLOT(slotStateChanged(Phonon::State,Phonon::State)));
643
        connect(m_media[i], SIGNAL(prefinishMarkReached(qint32)), SLOT(slotNeedNextUrl()));
644 645 646
        connect(m_media[i], SIGNAL(totalTimeChanged(qint64)), SLOT(slotLength(qint64)));
        connect(m_media[i], SIGNAL(tick(qint64)), SLOT(slotTick(qint64)));
        connect(m_media[i], SIGNAL(finished()), SLOT(slotFinished()));
647
        connect(m_media[i], SIGNAL(seekableChanged(bool)), SLOT(slotSeekableChanged(bool)));
648
    }
649

650 651
    // initialize action states

652 653 654 655
    action("pause")->setEnabled(false);
    action("stop")->setEnabled(false);
    action("back")->setEnabled(false);
    action("forward")->setEnabled(false);
656
    action("forwardAlbum")->setEnabled(false);
657

658
    QDBusConnection::sessionBus().registerObject("/Player", this);
659 660 661
}

void PlayerManager::slotUpdateGuiIfStopped()
662
{
663 664 665
    if(m_media[0]->state() == Phonon::StoppedState && m_media[1]->state() == Phonon::StoppedState)
        stop();
}
666

667 668 669
void PlayerManager::crossfadeToFile(const FileHandle &newFile)
{
    int nextOutputPath = 1 - m_curOutputPath;
670

671 672 673
    // Don't need this anymore
    disconnect(m_media[m_curOutputPath], SIGNAL(finished()), this, 0);
    connect(m_media[nextOutputPath], SIGNAL(finished()), SLOT(slotFinished()));
674

675
    m_fader[nextOutputPath]->setVolume(0.0f);
676

677
    emit signalItemChanged(newFile);
678 679
    m_media[nextOutputPath]->setCurrentSource(newFile.absFilePath());
    m_media[nextOutputPath]->play();
680

681 682
    m_fader[m_curOutputPath]->setVolume(1.0f);
    m_fader[m_curOutputPath]->fadeTo(0.0f, 2000);
683

684
    m_fader[nextOutputPath]->fadeTo(1.0f, 2000);
685

686 687
    m_curOutputPath = nextOutputPath;
}
688

689 690 691 692
void PlayerManager::stopCrossfade()
{
    // According to the Phonon docs, setVolume immediately takes effect,
    // which is "good enough for government work" ;)
693

694 695 696
    // 1 - curOutputPath is the other output path...
    m_fader[m_curOutputPath]->setVolume(1.0f);
    m_fader[1 - m_curOutputPath]->setVolume(0.0f);
697

698 699 700 701 702 703 704 705
    // We don't actually need to physically stop crossfading as the playback
    // code will call ->play() when necessary anyways.  If we hit stop()
    // here instead of pause() then we will trick our stateChanged handler
    // into thinking Phonon had a spurious stop and we'll switch tracks
    // unnecessarily.  (This isn't a problem after crossfade completes due to
    // the signals being disconnected).

    m_media[1 - m_curOutputPath]->pause();
706 707
}

Michael Pyne's avatar
Michael Pyne committed
708 709 710 711 712 713 714 715 716
QString PlayerManager::randomPlayMode() const
{
    if(action<KToggleAction>("randomPlay")->isChecked())
        return "Random";
    if(action<KToggleAction>("albumRandomPlay")->isChecked())
        return "AlbumRandom";
    return "NoRandom";
}

717 718 719 720 721
void PlayerManager::setCrossfadeEnabled(bool crossfadeEnabled)
{
    m_crossfadeTracks = crossfadeEnabled;
}

Michael Pyne's avatar
Michael Pyne committed
722 723
void PlayerManager::setRandomPlayMode(const QString &randomMode)
{
724
    if(randomMode.toLower() == "random")
Michael Pyne's avatar
Michael Pyne committed
725
        action<KToggleAction>("randomPlay")->setChecked(true);
726
    if(randomMode.toLower() == "albumrandom")
Michael Pyne's avatar
Michael Pyne committed
727
        action<KToggleAction>("albumRandomPlay")->setChecked(true);
728
    if(randomMode.toLower() == "norandom")
Michael Pyne's avatar
Michael Pyne committed
729 730 731
        action<KToggleAction>("disableRandomPlay")->setChecked(true);
}

732
#include "playermanager.moc"
733

734
// vim: set et sw=4 tw=0 sta: