vendredi 9 décembre 2011

Réaliser un lecteur video en QML

Bonjour,

Aujourd'hui je vais vous exposer une solution Qt Quick permettant de jouer une video en plein écran avec contrôle du lecteur (Play/Pause), réglage du son avec les touches matérielles ou depuis l'interface tactile via un slider. Par ailleurs je forcerai le mode "paysage" pour lire la vidéo ; pour le changement d'orientation de l'écran je vous invite à lire mon précédent article.

L'application se décompose en plusieurs écrans :
  1. Une page d'accueil avec trois boutons :
    • un bouton "local video" permettant de lire une video locale embarquée dans le binaire
    • un bouton "remote video" permettant de lire une video en streaming
    • un bouton "quitter"

  
2.   Visonnage de la video locale sans barre de contrôle :



3. Visionnage de la video locale avec barres de contrôle en overlay :



4. Ecran de chargement de la vidéo remote (streaming) :



5.  Lecture la video en streaming :




Commençons par le point d'entrée, à savoir la fonction main du fichier main.cpp :

int main(int argc, char *argv[])
{
    QApplication app(argc, argv);

    QmlApplicationViewer viewer;
    viewer.setOrientation(QmlApplicationViewer::ScreenOrientationLockPortrait);


    // Set the screen size to QML context
    QDeclarativeContext* context = viewer.rootContext();
    context->setContextProperty("appviewer", &viewer);


    qmlRegisterType<MediakeyCaptureItem>("Mediakey", 1, 0, "MediakeyCapture");
    qmlRegisterType<QmlApplicationViewer>("AppViewer", 1, 0, "QmlApplicationViewer");


    viewer.setMainQmlFile(QLatin1String("qml/VideoTest/main.qml"));
    viewer.showExpanded();

    return app.exec();
}

Deux objets C++ importants sont déclarés dans le contexte déclaratif :
  • un objet de type MediakeyCapture dont le rôle est d'intercepter les événements de touche hardware "Sound Up" et  "Sound Down" situés latéralement sur les téléphones Nokia. Je vous invite à lire ce wiki très bien fait qui explique comment capturer les événements associés à ces touches (il s'agit d'un hack symbian car les signaux Keys.OnVolumeDownPressed et Keys.OnVolumeUpPressed ne sont pas remontés au niveau QML)
  • un objet de type QmlApplicationViewer permettant de forcer le mode d'orientation de manière programmatique. Pour la description complète de cette classe, je vous invite à lire l'article correspondant ici
Côté QML, j'ai fait à l'ancienne sans utiliser les pages stacks des composants Qt Quick pour Symbian : j'ai écrit deux fichiers QML :
  1. Main.qml : il décrit la page principale avec ces trois boutons.
  2. VideoPlayer.qml : il décrit le player video en mode fullscreen paysage
Détail de Main.qml

C'est un rectangle dimensionné selon le terminal de test (640x360) dans mon cas :

Rectangle {
    id: main
    width: 360
    height: 640

Avec la définition d'un signal et de son slot associé utilisé pour lancer le lecteur video :

signal displayVideoPlayer(string video_url)
onDisplayVideoPlayer: {
  console.log("onDisplayVideoPlayer");
  mainLoader.source = "VideoPlayer.qml"
  mainLoader.item.source = video_url
  mainLoader.item.play()
  main.state = "videoState"
  appviewer.setOrientation(QmlApplicationViewer.ScreenOrientationLockLandscape)
}

Pour lancer le player video :
  • j'utilise un élément Loader (identifiant "mainLoader") qui permet de charger à la demande une ressource QML (fichier QML dénommé VideoPlayer.qml)
  • L'objet VideoPlayer possède une propriété importante : l'url pointant sur la video (locale ou réseau)
  • l'élément main dispose de deux états : "homeState" et "videoState" que je décrirais ci-dessous. Lorsque je lance la vidéo, je passe à l'état "videoState"
  • je force l'écran à basculer en mode paysage
Ensuite il y a la définition des deux états :

state:"homeState"
states: [
    State {
        name: "videoState"
        PropertyChanges {
            target: mainView
            visible: false
        }
        PropertyChanges {
            target: mainLoader
            visible: true
        }
    },
    State {
        name: "homeState"
        PropertyChanges {
            target: mainView
            visible: true
        }
        PropertyChanges {
            target: mainLoader
            visible: false
        }
    }
]

Deux états pour deux écrans :
  • un état "homeState" (état au démarrage) rendant visible la page d'accueil avec les trois boutons et masquant le loader dont la source correspond au lecteur video
  • un état "videoState" masquant la page d'accueil au profit du lecteur video
Ensuite vient la construction de la page d'accueil avec les trois boutons :

Rectangle{
    id: mainView
    anchors.fill: parent

       // un gradient pour faire joli

    gradient: Gradient {
        GradientStop {
            position: 0.0
            color: "#c5cc94"
        }

        GradientStop {
            position: 0.8
            color: "#438048"
        }

        GradientStop {
            position: 1
            color: "#000000"
        }
    }
        // un bouton pour lancer la video locale

    Button {
        anchors.bottom: launchRemoteVideoButton.top
        anchors.bottomMargin: 40
        anchors.horizontalCenter: parent.horizontalCenter
        text:"Local video"
        width: 170

        onClicked: {
            main.displayVideoPlayer("video.mp4");
        }
    }
        // un bouton pour lancer la video réseau

    Button {
        id: launchRemoteVideoButton
        anchors.centerIn: parent
        text:"Remote video"
        width: 170

        onClicked: {
            main.displayVideoPlayer("http://iop1.nokia-boston.com/HTML5/Video/Phase1/the-ninja-cat.mp4");
        }
    }
        // un bouton pour quitter

    Button {
        id: quitButton
        anchors.top: launchRemoteVideoButton.bottom
        anchors.topMargin: 40
        anchors.horizontalCenter: parent.horizontalCenter
        text:"Quit"
        width: 170

        onClicked: {
            Qt.quit()
        }
    }
}


J'utilise donc le composant Qt Quick pour Symbian "Bouton" avec appel du signal "displayVideoPlayer" sur le slot "onClicked" de manière à déclencher l'affichage du lecteur video.

Enfin je créé le loader permettant de charger le lecteur video de manière dynamique :

Loader{
    z: 1
    id: mainLoader
    anchors.fill: parent
}

 
Connections{
    target: mainLoader.item
    ignoreUnknownSignals: true

    onVideoChangeBackTo:{
        console.log("Return to home page")
        appviewer.setOrientation(QmlApplicationViewer.ScreenOrientationLockPortrait)
        mainLoader.source=""
        main.state="homeState"
    }
}


Cet élément de connexion au signal "VideoChangeBackTo" me permet de traiter le retour à la page d'accueil lorsque la vidéo s'est terminée : je repositionne l'écran en mode paysage et je bascule dans l'état "homeState".

Détail de VideoPlayer.qml

Le player video est une zone rectangulaire avec un fond noir et une méthode "play" pour démarrer le visionnage de la vidéo et un signal "videoChangeBackto" pour indiquer la fin de ce même visionnage :

import QtQuick 1.1
import QtMultimediaKit 1.1
import com.nokia.symbian 1.1    // Symbian components
import Mediakey 1.0


Rectangle {
    id: videoPlayer
    property alias source: video.source
    property real opacityValue: 0.6

    signal videoChangeBackTo()

    function play(){
        video.play()
    }
        width: 640
    height: 360
    color: "black"


Comme nous allons le voir d'ici peu, "video" est l'identifiant de l'élément QML Video. opacityValue correspond au degré de transparence des barres de contrôles en overlay.

Ensuite je définis deux états pour controller l'affichage des barres de contrôle du lecteur vidéo :

    state: "hideControlBar"
    states: [
        State {
            name: "showControlBar"
            PropertyChanges {
                target: topControlBar
                visible:true
            }
            PropertyChanges {
                target: bottomControlBar
                visible:true
            }
            PropertyChanges {
                target: videoPlayerMouseArea
                enabled:false
            }
        },
        State {
            name: "hideControlBar"
            PropertyChanges {
                target: topControlBar
                visible:false
            }
            PropertyChanges {
                target: bottomControlBar
                visible:false
            }
            PropertyChanges {
                target: videoPlayerMouseArea
                enabled:true
            }
        }
    ]


topControlBar est l'identifiant de la barre de contrôle supérieure contenant le bouton de fermeture et le slider pour ajuster le voume sonore. bottomControlBar est l'identifiant de la barre de contrôle inférieure contenant le bouton play/pause ainsi que la barre de progression temporelle. Enfin "videoPlayerMouseArea" correspond à toute la zone interactive du lecteur video.

Globalement l'état "showControlBar" définit le lecteur video avec les barres visibles en overlay (et la zone "videoPlayerMouseArea" désactivée et l'état "hideControlBar" définit le lecteur video sans les barres de contrôle mais avec toute la surface tactile activée. Dit simplement, ce mécanisme à deux états permet d'afficher les contrôles lorsque l'utilisateur clique sur la video. Par ailleurs la durée d'affichage de ces barres de contrôles est conditionnée à l'expiration d'un timer de 10 secondes comme le montre la suite du code :

Timer{
    id:controlBarTimer
    interval:10000
    running:true
    onTriggered:{videoPlayer.state="hideControlBar"}
}

Vient ensuite un élément indispensable : le fameux élément QML Video :

Video{
    id: video

    anchors.centerIn: parent
    width: parent.width
    height: parent.height


    Component.onCompleted: {
        console.log("Video Component.onCompleted")
        volume = 0.2
        videoPlayer.state="hideControlBar"
        loadingScreen.state = "showLoadingScreen"
    }

    onStopped:
    {
        console.log("Video onStopped signal received")
        mediaCapture.destroy()
        videoPlayer.videoChangeBackTo()
        loadingScreen.state = "hideLoadingScreen"
    }

    onStatusChanged:
    {
        console.log("Video onStatusChanged signal received")
        console.log("Status : "+status)

        if(status == Video.NoMedia)
        {
            console.log("Status : NoMedia")
        }
        else if(status == Video.Loading)
        {
            console.log("Status : Loading")
        }
        else if(status == Video.Loaded)
        {
            console.log("Status : Loaded")
            loadingScreen.state = "hideLoadingScreen"
        }
        else if(status == Video.Buffering)
        {
            console.log("Status : Buffering")
        }
        else if(status == Video.Stalled)
        {
            console.log("Status : Stalled")
        }
        else if(status == Video.Buffered)
        {
            console.log("Status : Buffered")
        }
        else if(status == Video.EndOfMedia)
        {
            console.log("Status : EndOfMedia")
        }
        else if(status == Video.InvalidMedia)
        {
            console.log("Status : InvalidMedia")
        }
        else if(status == Video.UnknownStatus)
        {
            console.log("Status : UnknownStatus")
        }
    }
}

Cette surface video prend tout l'écran et voit son initialisation réalisée après son chargement (qui correspond à l'émission du signal "onCompleted". Dans le slot associé, je positionne le volume à 0.2 (soit 20% du volume max) car par défaut le son est au maximum sur Symbian, ce qui est intrusif lorsqu'on lit une video dans le métro. Lorsque le player se lance, je masque les barres de contrôles et j'affiche l'écran de chargement.

Ensuite je définis les gestionnaires des signaux "Stopped" et "StatusChanged" :
  • Signal Stopped : je détruis l'objet "mediaCapture" (afin qu'il soit réutilisable lors du prochain visionnage) ; j'émets le signal videoChangeBackTo afin que ce dernier soit récupéré par l'élément QML Main pour afficher la page d'accueil
  • Signal StatusChanged : je m'intéresse uniquement à l'état "Loaded" du lecteur afin de masquer l'acran de chargement quand la video est prête à s'afficher. Noter que dans le carde d'une application plus robuste, il est nécessaire de monitorer les différents status, notamment pour la gestion d'erreurs
Je mets en place mon objet "MediaCapture" pour intercepter les événements matériels "Sound Up" et "Sound Down" :

MediakeyCapture{
    id: mediaCapture
    onVolumeDownPressed:{
        console.log('VOLUME DOWN PRESSED ')
        if(video.volume>0.1) video.volume -= 0.1
    }
    onVolumeUpPressed:{
        console.log('VOLUME UP PRESSED ')
        if(video.volume<1) video.volume += 0.1
    }
}

Rien de bien compliqué ici : j'ajuste le volume en conséquence. Vous verrez par la suite que grâce au binding de la propriété "volume" avec le slider visuel de contrôle de volume, ce dernier ce mettra automatiquement à jour lorsqu'on ajustera le volume depuis ces touches claviers.

Après je définis mon écran de chargement avec une indicateur animé (composant BusyIndicator) :

Rectangle{
    id: loadingScreen
    anchors.centerIn: parent
    color: "black"
    visible: true

    state: "showLoadingScreen"
    states: [
        State {
            name: "showLoadingScreen"
            PropertyChanges {
                target: loadingScreen
                visible:true
                z:1
            }
        },
        State {
            name: "hideLoadingScreen"
            PropertyChanges {
                target: loadingScreen
                visible:false
                z:0
            }
        }
    ]

    Text{
        id: bgText
        anchors.centerIn: parent
        color: "white"
        text: "Chargement de la vidéo ..."
    }

    BusyIndicator {
        id: indicator
        anchors.verticalCenter: parent.verticalCenter
        anchors.right: bgText.left
        anchors.rightMargin: 20
        running: true
    }
  }


Noter la définition de deux états "showLoadingScreen" et "hideLoadingScreen" permettant de jouer sur la visibilité de cette page de chargement.

Ensuite je définis la barre de contrôle supérieure avec le bouton de fermetture et le slider d'ajustement de volume :

Rectangle {
    id: topControlBar
    height: 66
    width: parent.width
    color: "black"
    opacity: parent.opacityValue
    anchors.top:parent.top

    Button {
        id: closeButton
        iconSource: "images/close.png"
        width: 64
        height: 64
        anchors{left:parent.left;leftMargin:10;}
        anchors.verticalCenter: parent.verticalCenter
        MouseArea{
            anchors.fill: parent
            onClicked: {
                videoPlayer.state="hideControlBar"
                video.stop()                    
            }
        }
    }

    Image {
        id: soundOff
        source: "images/sound_off.png"
        anchors.left:closeButton.right
        anchors.leftMargin:20
        anchors.verticalCenter: parent.verticalCenter
        width: 48
        height: 48
    }

    Slider {
        id:soundBar
        anchors{left:soundOff.right;leftMargin:10;}
        anchors.verticalCenter: parent.verticalCenter

        height: 10
        width: 640-x-soundOn.width-20

        maximumValue: 1.0
        minimumValue: 0
        value: video.volume
        stepSize: 0.1
        valueIndicatorVisible: true
        valueIndicatorText: "Volume"

        onValueChanged: {
            console.log(value)
            video.volume = value
        }

        MouseArea{
            anchors.fill: parent
        }
     }

     Image {
        id: soundOn
        source: "images/sound_on.png"
        anchors{left:soundBar.right;leftMargin:10;}
        anchors.verticalCenter: parent.verticalCenter
        width: 48
        height: 48
     }
}


Les points principaux à noter concernant le code ci-dessus sont :
  • Le gestionnaire de clic sur le bouton "close" : on masque les barres de contrôle et on arrête le lecteur video
  • Le binding de la propriété "value" du slider avec le volume
On continue avec cette fois la barre de contrôle inférieure affichant le bouton Play/Pause ainsi que la barre de progression temporelle :

Rectangle {
    id: bottomControlBar
    height: 66
    width: parent.width
    color: "black"
    opacity: parent.opacityValue
    anchors.bottom: parent.bottom

    Button {
        id: playPause
        width: 64
        height:64
        anchors.left: parent.left
        anchors.leftMargin: 10
        anchors.verticalCenter: parent.verticalCenter

        state: "clicked"
        states: [
            State{
                name: "clicked"
                PropertyChanges {
                    target: playPause
                    iconSource: "images/pause.png"
                }
            },
            State{
                name: "unclicked"
                PropertyChanges {
                    target: playPause
                    iconSource: "images/play.png"
                }
            }
        ]

        onClicked: {

            if(video.paused==false && video.playing==true)
            {
                controlBarTimer.stop()
                videoPlayer.state="showControlBar"
                video.pause()
                playPause.state="unclicked"
            }
            else {
                controlBarTimer.restart()
                video.play()
                playPause.state="clicked"
            }
        }
    }

    Slider {
         id:progressBar
         anchors{left:playPause.right;leftMargin:20;}
         anchors.verticalCenter: parent.verticalCenter
         height: 10
         width: 640-20-x

         maximumValue: video.duration
         minimumValue: 0
         value: video.position
         stepSize: 1000
         valueIndicatorVisible: true
         valueIndicatorText:convertToMinuteSecond(video.position)+" / "+convertToMinuteSecond(video.duration)

         onPressedChanged:
         {
             if(!pressed)
             {
                 video.position = value
             }
         }

         onValueChanged: {
             console.log(value)
         }

         function convertToMinuteSecond(ms){
             var ti=ms
             var myDate = new Date(ti);
             return (myDate.getMinutes()<10?("0"+myDate.getMinutes()):myDate.getMinutes())+":"+(myDate.getSeconds()<10?("0"+myDate.getSeconds()):myDate.getSeconds());
         }

         MouseArea{
             anchors.fill: parent
         }
    }
}

Noter le binding de la bulle (propriété valueIndicatorText ) avec la méthode convertToMinuteSecond permettant de convertir la valeur temporelle écoulée en minute/seconde. Lorsqu'on relâche l'indicateur du slider, la position de la video est automatiquement modifiée.

Enfin pour conclure, il y a l'élément MouseArea permettant de faire apparaître les barres de contrôle en overlay :

MouseArea{
    id:videoPlayerMouseArea
    anchors.fill: parent
    onClicked: {
        videoPlayer.state="showControlBar"
        controlBarTimer.start()
    }
}

L'apparition des barres de contrôles sont déclenchées par timer pour une durée maximum de 10 secondes.

C'est fini : le code complet est disponible ici (pour des raisons de taille de stockage, j'ai dû supprimer la vidéo locale)

Aucun commentaire:

Enregistrer un commentaire