mercredi 14 décembre 2011

Diaporama (ou slideshow) QML

Un pattern graphique souvent utilisé dans les applis mobiles est le diaporama d'images : l'expérience doit être intuitive, fluide et immersive. Pour le dernier point, il est recommandé de forcer l'orientation en mode paysage (voir cet article pour plus d'infos). Pour l'aspect intuitif il est classique d'utiliser son doigt pour balayer l'écran (swipe en anglais) de droite à gauche (resp gauche à droit) afin de faire défiler les images. Voici le rendu de l'appli que je propose aujourd'hui :



Les flèches latérales indiquent la possibilité de glisser son doigt vers la droite ou vers la gauche :



J'ai créé un projet avec deux fichiers QML : main.qml et Diaporama.qml ; le fichier main.qml contient à peu de chose près un code similaire à celui décrit dans l'article de lecteur video ; intéressons-nous au second fichier en charge d'afficher le diaporama : les images sont stockées localement dans le fichier binaire mais rien ne vous empêche de récupérer les images depuis le réseau par exemple :

Rectangle {
    id: diaporama

    signal leaveDiaporama()

    width: 640
    height: 360
    color: "black"


Le diaporama est un écran en mode paysage avec un fond noir et un signal émis lorsque l'utilisateur clique sur la croix. Nous définissons ensuite un modèle statique de type ListModel:

ListModel {
  id: myListModel
  ListElement { file: "./images/0.jpg"; name: "Picture n°0"; desc:"Lorem ipsum dolor sit amet"}
  ListElement { file: "./images/1.jpg"; name: "Picture n°1"; desc:"Lorem ipsum dolor sit amet"}
  ListElement { file: "./images/2.jpg"; name: "Picture n°2"; desc:"Lorem ipsum dolor sit amet"}
  ListElement { file: "./images/3.jpg"; name: "Picture n°3"; desc:"Lorem ipsum dolor sit amet"}
  ListElement { file: "./images/4.jpg"; name: "Picture n°4"; desc:"Lorem ipsum dolor sit amet"}
  ListElement { file: "./images/5.jpg"; name: "Picture n°5"; desc:"Lorem ipsum dolor sit amet"}
  ListElement { file: "./images/6.jpg"; name: "Picture n°6"; desc:"Lorem ipsum dolor sit amet"}
  ListElement { file: "./images/7.jpg"; name: "Picture n°7"; desc:"Lorem ipsum dolor sit amet"}
  ListElement { file: "./images/8.jpg"; name: "Picture n°8"; desc:"Lorem ipsum dolor sit amet"}
  ListElement { file: "./images/9.jpg"; name: "Picture n°9"; desc:"Lorem ipsum dolor sit amet"}
  ListElement { file: "./images/10.jpg"; name: "Picture n°10"; desc:"Lorem ipsum dolor sit amet"}
  ...
}


Chaque élément représente un slide symbolisé par la triplette suivante :
  • "file" : chemin pointant sur l'image
  • "name" : titre du slide
  • "desc" : légende du slide
Dans une application live, les images sont récupérées du réseau et gérées dans un modèle de type ListModel, XmlListModel ou autre. Visuellement le diaporama sera réalisé avec une liste horizontale, elle-même agencée dans une mise en page de type "Row" :

Row {
    anchors.fill: parent

    Rectangle{
        width: 64
        height: parent.height
        color: "black"
        z:1
        Image {
            id: leftArrow
            source: "images/left_arrow.png"
            width: 26
            anchors.centerIn: parent
        }
    }

    ListView{
        id:listView
        orientation:ListView.Horizontal
        width:parent.width-closeButton.width*2
        height: parent.height
        model:myListModel
        delegate:myDelegate
        maximumFlickVelocity:700
        snapMode: ListView.SnapToItem
                preferredHighlightBegin: 0; preferredHighlightEnd: 0
        highlightRangeMode: ListView.StrictlyEnforceRange
        highlightFollowsCurrentItem: true

        onCurrentIndexChanged: {
            if(listView.currentIndex == 0) leftArrow.visible = false;
            else leftArrow.visible = true;

            if(listView.currentIndex == listView.count-1) rightArrow.visible = false;
            else rightArrow.visible = true;
        }
    }

    Rectangle{
        width: 64
        height: parent.height
        color: "black"
        Button {
            id: closeButton
            iconSource: "images/close.png"
            width: 64

            MouseArea{
                anchors.fill: parent
                onClicked: {
                    leaveDiaporama()
                }
            }
        }

        Image {
            id: rightArrow
            source: "images/right_arrow.png"
            width: 26
            anchors.centerIn: parent
        }
    }
}


Les rectangles de droite et de gauche permettent d'afficher des petites flêches indiquant si des photos sont disponibles en slidant dans chacune des directions. La propriété orientation est donc positionnée à la valeur ListView.Horizontal pour le défilement latéral. La propriété delegate pointe sur le composant en charge de dessiner un slide identifié myDelegate (nous le décrirons juste après). La prorpriété maximumFlickVelocity indique la vitesse maximale de défilement en pixels/seconde. La propriété snapMode permet de définir où s'arrête le défilement : la valeur par défaut ListView.NoSnap indique que le défilement s'arrête n'importe où ; dans notre cas nous souhaitons l'arrêter sur un item (en l'occurence un slide) : nous pouvons donc utiliser soit ListView.SnapToItem soit ListView.SnapOneItem. ensuite il nous faut pister la valeur courante de l'index afin d'afficher ou de masquer les flêches latérales. Pour cela il faut fixer la propriété highlightRangeMode à ListView.StrictlyEnforceRange. Ainsi le signal onCurrentIndexChanged sera émis à chaque sélection/défilement de slide : nous pouvons alors jouer sur la visibilité des flêches droite/gauche.

Pour finir, je définis le composant delegate en charge de dessiner le slide composé d'un titre, d'une photo et d'une description :

Component{
    id:myDelegate

    Rectangle {
        width:ListView.view.width
        height:ListView.view.height
        color: "black"

        // Title
        Text{
            id:myTitle
            anchors.horizontalCenter: parent.horizontalCenter
            anchors.bottom: myImage.top
            anchors.bottomMargin: 10
            color:"white"
            font.bold: true
            font.pointSize: 9
            elide:Text.ElideRight
            text: name
        }

        // Image
        Image{
            id:myImage
            anchors.centerIn: parent
            source: file
        }

        // Description
        Text{
            id:txt
            width: parent.width
            color:"white"
            text:desc
            font.pointSize: 7
            wrapMode : Text.WordWrap
            horizontalAlignment: Text.AlignHCenter
            anchors.top:myImage.bottom
            anchors.topMargin:10
            anchors.left: myImage.left
            anchors.right: myImage.right
        }
    }
}


Le titre du slide est bindé avec la propriété "name" du modèle myListModel. L'image est initialisée à partir de la source et le descriptif à partir du champ "desc".

Et voilà.
Le code complet est disponible ici

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)