Merge lp:~renatofilho/mediaplayer-app/pocket-pc into lp:mediaplayer-app

Proposed by Renato Araujo Oliveira Filho
Status: Merged
Approved by: Bill Filler
Approved revision: 398
Merged at revision: 386
Proposed branch: lp:~renatofilho/mediaplayer-app/pocket-pc
Merge into: lp:mediaplayer-app
Diff against target: 558 lines (+322/-54)
9 files modified
data/CMakeLists.txt (+5/-0)
data/mediaplayer-app-content.json (+5/-0)
debian/mediaplayer-app.install (+1/-0)
src/mediaplayer.cpp (+55/-0)
src/mediaplayer.h (+1/-0)
src/qml/player.qml (+42/-16)
src/qml/player/Controls.qml (+6/-3)
src/qml/player/ToolBar.qml (+122/-0)
src/qml/player/VideoPlayer.qml (+85/-35)
To merge this branch: bzr merge lp:~renatofilho/mediaplayer-app/pocket-pc
Reviewer Review Type Date Requested Status
PS Jenkins bot continuous-integration Needs Fixing
Ubuntu Phablet Team Pending
Review via email: mp+283299@code.launchpad.net

Commit message

*Implement support for content hub.
*Use themed icons for full screen button.

To post a comment you must log in.
Revision history for this message
PS Jenkins bot (ps-jenkins) wrote :
review: Needs Fixing (continuous-integration)
384. By Renato Araujo Oliveira Filho

Implement support for content hub.

Copy imported files to user video dir and play the video.

385. By Renato Araujo Oliveira Filho

Does not show the empty ulr dialgo if the app was launched by content hub.

386. By Renato Araujo Oliveira Filho

Does not set the app visibility to defaul on app startup.

387. By Renato Araujo Oliveira Filho

Pause video before start sekking to avoid problems with the player.

388. By Renato Araujo Oliveira Filho

Wait the app full load before check for empty url.

Avoid problems with content hub hasPending property.

389. By Renato Araujo Oliveira Filho

Fixed wrong return for copyFiles function.

Apped the newFile path into the result list.

Revision history for this message
PS Jenkins bot (ps-jenkins) wrote :
review: Approve (continuous-integration)
390. By Renato Araujo Oliveira Filho

Check if the app was launched by content hub a bit late, to give it some time to load.

Revision history for this message
PS Jenkins bot (ps-jenkins) wrote :
review: Needs Fixing (continuous-integration)
391. By Renato Araujo Oliveira Filho

Fixed seek with media-hub.

Keep track of seek position to use in consecutives seek operation.

Revision history for this message
PS Jenkins bot (ps-jenkins) wrote :
review: Approve (continuous-integration)
Revision history for this message
PS Jenkins bot (ps-jenkins) wrote :
review: Approve (continuous-integration)
392. By Renato Araujo Oliveira Filho

Created a toolbar component to allow us to control the hide/show animation.
Make sure that the controls are visible before start seeking.

Revision history for this message
PS Jenkins bot (ps-jenkins) wrote :
review: Needs Fixing (continuous-integration)
393. By Renato Araujo Oliveira Filho

Remove unecessary mouser area.

394. By Renato Araujo Oliveira Filho

Remove debug message.

Revision history for this message
PS Jenkins bot (ps-jenkins) wrote :
review: Needs Fixing (continuous-integration)
395. By Renato Araujo Oliveira Filho

Reduce controls timeout.
Fix control dissapearing after a seek.

Revision history for this message
PS Jenkins bot (ps-jenkins) wrote :
review: Needs Fixing (continuous-integration)
396. By Renato Araujo Oliveira Filho

Updated toolbar behaviour.

Revision history for this message
PS Jenkins bot (ps-jenkins) wrote :
review: Needs Fixing (continuous-integration)
397. By Renato Araujo Oliveira Filho

Trunk merged.

Revision history for this message
PS Jenkins bot (ps-jenkins) wrote :
review: Needs Fixing (continuous-integration)
398. By Renato Araujo Oliveira Filho

Revert changes on play/pause behavior.

Revision history for this message
PS Jenkins bot (ps-jenkins) wrote :
review: Needs Fixing (continuous-integration)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'data/CMakeLists.txt'
--- data/CMakeLists.txt 2014-10-02 13:42:17 +0000
+++ data/CMakeLists.txt 2016-01-26 22:05:58 +0000
@@ -2,6 +2,7 @@
2set(MEDIAPLAYER_APP_ICON mediaplayer-app.png)2set(MEDIAPLAYER_APP_ICON mediaplayer-app.png)
3set(MEDIAPLAYER_APP_SYMBOLIC_ICON mediaplayer-app-symbolic.svg)3set(MEDIAPLAYER_APP_SYMBOLIC_ICON mediaplayer-app-symbolic.svg)
4set(MEDIAPLAYER_URL_DISPATCHER mediaplayer-app.url-dispatcher)4set(MEDIAPLAYER_URL_DISPATCHER mediaplayer-app.url-dispatcher)
5set(MEDIAPLAYER_CONTENT_HUB mediaplayer-app-content.json)
56
6configure_file(${DESKTOP_FILE}.in.in ${DESKTOP_FILE}.in)7configure_file(${DESKTOP_FILE}.in.in ${DESKTOP_FILE}.in)
7add_custom_target(${DESKTOP_FILE} ALL8add_custom_target(${DESKTOP_FILE} ALL
@@ -20,3 +21,7 @@
20install(FILES ${MEDIAPLAYER_URL_DISPATCHER}21install(FILES ${MEDIAPLAYER_URL_DISPATCHER}
21 DESTINATION ${CMAKE_INSTALL_DATADIR}/url-dispatcher/urls22 DESTINATION ${CMAKE_INSTALL_DATADIR}/url-dispatcher/urls
22)23)
24
25install(FILES ${MEDIAPLAYER_CONTENT_HUB}
26 DESTINATION ${CMAKE_INSTALL_DATADIR}/content-hub/peers
27 RENAME mediaplayer-app)
2328
=== added file 'data/mediaplayer-app-content.json'
--- data/mediaplayer-app-content.json 1970-01-01 00:00:00 +0000
+++ data/mediaplayer-app-content.json 2016-01-26 22:05:58 +0000
@@ -0,0 +1,5 @@
1{
2 "destination": [
3 "videos"
4 ]
5}
06
=== modified file 'debian/mediaplayer-app.install'
--- debian/mediaplayer-app.install 2014-09-29 20:53:44 +0000
+++ debian/mediaplayer-app.install 2016-01-26 22:05:58 +0000
@@ -5,3 +5,4 @@
5/usr/share/mediaplayer-app/qml/*5/usr/share/mediaplayer-app/qml/*
6/usr/share/locale/*/LC_MESSAGES/mediaplayer-app.mo6/usr/share/locale/*/LC_MESSAGES/mediaplayer-app.mo
7/usr/share/url-dispatcher/urls/*7/usr/share/url-dispatcher/urls/*
8/usr/share/content-hub/peers/mediaplayer-app
89
=== modified file 'src/mediaplayer.cpp'
--- src/mediaplayer.cpp 2016-01-05 15:12:20 +0000
+++ src/mediaplayer.cpp 2016-01-26 22:05:58 +0000
@@ -25,6 +25,7 @@
25#include <QtCore/QLibrary>25#include <QtCore/QLibrary>
26#include <QtCore/QTimer>26#include <QtCore/QTimer>
27#include <QtCore/QStandardPaths>27#include <QtCore/QStandardPaths>
28#include <QtCore/QMimeDatabase>
28#include <QtWidgets/QFileDialog>29#include <QtWidgets/QFileDialog>
29#include <QtQml/QQmlContext>30#include <QtQml/QQmlContext>
30#include <QtQml/QQmlEngine>31#include <QtQml/QQmlEngine>
@@ -216,3 +217,57 @@
216217
217 return fileName;218 return fileName;
218}219}
220
221QList<QUrl> MediaPlayer::copyFiles(const QList<QUrl> &urls)
222{
223 static QString moviesDir = QStandardPaths::writableLocation(QStandardPaths::MoviesLocation);
224
225 QList<QUrl> result;
226
227 Q_FOREACH(const QUrl &url, urls) {
228 if (!url.isLocalFile()) {
229 qWarning() << "Remote files not supported:" << url;
230 continue;
231 }
232
233 QFileInfo originalFile(url.toLocalFile());
234
235 QString filename = originalFile.fileName();
236 QString suffix = originalFile.completeSuffix();
237 QString filenameWithoutSuffix;
238 if (suffix.isEmpty()) {
239 QMimeDatabase mdb;
240 QMimeType mt = mdb.mimeTypeForFile(originalFile.absoluteFilePath());
241
242 // If the filename doesn't have an extension add one from the
243 // detected mimetype
244 if(!mt.preferredSuffix().isEmpty()) {
245 suffix = mt.preferredSuffix();
246 }
247 filenameWithoutSuffix = filename;
248 } else {
249 filenameWithoutSuffix = originalFile.baseName();
250 }
251
252 QFileInfo newFile(moviesDir, QString("%1.%2").arg(filenameWithoutSuffix).arg(suffix));
253 if (newFile.exists()) {
254 // find a alternative name
255 int index = 1;
256 do {
257 newFile = QFileInfo(moviesDir,
258 QString("%1(%2).%3")
259 .arg(filenameWithoutSuffix)
260 .arg(index)
261 .arg(suffix));
262 index++;
263 } while (newFile.exists());
264 }
265
266 if (QFile::copy(originalFile.absoluteFilePath(), newFile.absoluteFilePath())) {
267 result << QUrl::fromLocalFile(newFile.absoluteFilePath());
268 } else {
269 qWarning() << "Fail to copy file from:" << originalFile.absoluteFilePath() << "to" << newFile.absoluteFilePath();
270 }
271 }
272 return result;
273}
219274
=== modified file 'src/mediaplayer.h'
--- src/mediaplayer.h 2014-02-13 18:26:52 +0000
+++ src/mediaplayer.h 2016-01-26 22:05:58 +0000
@@ -40,6 +40,7 @@
40 void onHeightChanged(int);40 void onHeightChanged(int);
41 bool isDesktopMode() const;41 bool isDesktopMode() const;
42 QUrl chooseFile();42 QUrl chooseFile();
43 QList<QUrl> copyFiles(const QList<QUrl> &urls);
4344
44private:45private:
45 QQuickView *m_view;46 QQuickView *m_view;
4647
=== modified file 'src/qml/player.qml'
--- src/qml/player.qml 2015-04-28 18:56:10 +0000
+++ src/qml/player.qml 2016-01-26 22:05:58 +0000
@@ -25,6 +25,7 @@
25import Ubuntu.Unity.Action 1.1 as UnityActions25import Ubuntu.Unity.Action 1.1 as UnityActions
26import Ubuntu.Components 1.126import Ubuntu.Components 1.1
27import Ubuntu.Components.Popups 1.0 as Popups27import Ubuntu.Components.Popups 1.0 as Popups
28import Ubuntu.Content 0.1 as ContentHub
2829
29Item {30Item {
30 id: mediaPlayer31 id: mediaPlayer
@@ -35,7 +36,6 @@
35 property string formFactor: "phone"36 property string formFactor: "phone"
36 property real volume: playerLoader.item.volume37 property real volume: playerLoader.item.volume
37 property bool appActive: Qt.application.active38 property bool appActive: Qt.application.active
38
39 property variant nativeOrientation: Screen.primaryOrientation39 property variant nativeOrientation: Screen.primaryOrientation
4040
41 onAppActiveChanged: {41 onAppActiveChanged: {
@@ -84,16 +84,8 @@
84 item.focus = true84 item.focus = true
85 item.rotating = Qt.binding(function () { return rotatingTransition.running } )85 item.rotating = Qt.binding(function () { return rotatingTransition.running } )
86 if (playUri != "") {86 if (playUri != "") {
87 lateUrlCheck.stop()
87 item.playUri(playUri)88 item.playUri(playUri)
88 } else {
89 if (mpApplication.desktopMode) {
90 var videoFile = mpApplication.chooseFile()
91 if (videoFile != "") {
92 item.playUri(videoFile)
93 }
94 } else {
95 PopupUtils.open(dialogNoUrl, null)
96 }
97 }89 }
98 }90 }
9991
@@ -212,14 +204,10 @@
212 }204 }
213205
214 Keys.onReleased: {206 Keys.onReleased: {
215 if (!event.isAutoRepeat207 if (!event.isAutoRepeat && event.key === Qt.Key_BracketLeft) {
216 && (event.key == Qt.Key_F11 || event.key == Qt.Key_F)) {
217 event.accepted = true
218 application.toggleFullscreen();
219 } else if (!event.isAutoRepeat && event.key == Qt.Key_BracketLeft) {
220 event.accepted = true208 event.accepted = true
221 rotateClockwise()209 rotateClockwise()
222 } else if (!event.isAutoRepeat && event.key == Qt.Key_BracketRight) {210 } else if (!event.isAutoRepeat && event.key === Qt.Key_BracketRight) {
223 event.accepted = true211 event.accepted = true
224 rotateCounterClockwise()212 rotateCounterClockwise()
225 }213 }
@@ -229,9 +217,47 @@
229 target: UriHandler217 target: UriHandler
230 onOpened: {218 onOpened: {
231 for (var i = 0; i < uris.length; ++i) {219 for (var i = 0; i < uris.length; ++i) {
220 lateUrlCheck.stop()
232 var videoUri = uris[i].replace("video://", "file://")221 var videoUri = uris[i].replace("video://", "file://")
233 playerLoader.item.playUri(videoUri)222 playerLoader.item.playUri(videoUri)
234 }223 }
235 }224 }
236 }225 }
226
227 Connections {
228 target: ContentHub.ContentHub
229 onImportRequested: {
230 lateUrlCheck.stop()
231 if (transfer.state === ContentHub.ContentTransfer.Charged) {
232 var urls = []
233 for(var i=0; i < transfer.items.length; i++) {
234 urls.push(transfer.items[i].url)
235 }
236
237 var result = mpApplication.copyFiles(urls);
238 if (result.length > 0)
239 playerLoader.item.playUri(result[result.length - 1])
240 }
241 }
242 }
243
244 Timer {
245 id: lateUrlCheck
246
247 interval: 1000
248 repeat: false
249 running: true
250 onTriggered: {
251 if ((playUri == "") && !ContentHub.ContentHub.hasPending) {
252 if (mpApplication.desktopMode) {
253 var videoFile = mpApplication.chooseFile()
254 if (videoFile != "") {
255 playerLoader.item.playUri(videoFile)
256 }
257 } else {
258 PopupUtils.open(dialogNoUrl, null)
259 }
260 }
261 }
262 }
237}263}
238264
=== modified file 'src/qml/player/Controls.qml'
--- src/qml/player/Controls.qml 2015-03-20 17:34:56 +0000
+++ src/qml/player/Controls.qml 2016-01-26 22:05:58 +0000
@@ -19,6 +19,7 @@
19 * along with this program. If not, see <http://www.gnu.org/licenses/>.19 * along with this program. If not, see <http://www.gnu.org/licenses/>.
20 */20 */
21import QtQuick 2.021import QtQuick 2.0
22import QtQuick.Window 2.2
22import QtMultimedia 5.023import QtMultimedia 5.0
23import Ubuntu.Components 1.124import Ubuntu.Components 1.1
2425
@@ -152,13 +153,16 @@
152 id: _fullScreenButton153 id: _fullScreenButton
153154
154 //TODO: use the correct icon based on window state155 //TODO: use the correct icon based on window state
155 iconSource: mpApplication.desktopMode ? "artwork/icon_exitfscreen.png" : "image://theme/back"156 iconSource: mpApplication.desktopMode ?
157 Window.visibility === Window.FullScreen ? "image://theme/view-restore" : "image://theme/view-fullscreen" :
158 "image://theme/close"
156 iconSize: units.gu(3)159 iconSize: units.gu(3)
157 anchors.verticalCenter: parent.verticalCenter160 anchors.verticalCenter: parent.verticalCenter
158 width: units.gu(8)161 width: visible ? units.gu(8) : 0
159 height: units.gu(4)162 height: units.gu(4)
160 onClicked: controls.fullscreenClicked()163 onClicked: controls.fullscreenClicked()
161 leftAlignment: true164 leftAlignment: true
165 visible: (mpApplication.desktopMode || (Window.visibility === Window.FullScreen))
162 }166 }
163167
164 VLine {168 VLine {
@@ -341,7 +345,6 @@
341 Connections {345 Connections {
342 target: controls346 target: controls
343 onPlayerStatusChanged: {347 onPlayerStatusChanged: {
344 console.debug("onPlayerStatusChanged")
345 _timeline.playerStatus = controls.playerStatus348 _timeline.playerStatus = controls.playerStatus
346 }349 }
347 }350 }
348351
=== added file 'src/qml/player/ToolBar.qml'
--- src/qml/player/ToolBar.qml 1970-01-01 00:00:00 +0000
+++ src/qml/player/ToolBar.qml 2016-01-26 22:05:58 +0000
@@ -0,0 +1,122 @@
1/*
2 * Copyright (C) 2013 Canonical, Ltd.
3 *
4 * Authors:
5 * Renato Araujo Oliveira Filho <renato@canonical.com>
6 *
7 * This program is free software; you can redistribute it and/or modify
8 * it under the terms of the GNU General Public License as published by
9 * the Free Software Foundation; version 3.
10 *
11 * This program is distributed in the hope that it will be useful,
12 * but WITHOUT ANY WARRANTY; without even the implied warranty of
13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 * GNU General Public License for more details.
15 *
16 * You should have received a copy of the GNU General Public License
17 * along with this program. If not, see <http://www.gnu.org/licenses/>.
18 */
19
20import QtQuick 2.0
21import Ubuntu.Components 1.1
22
23
24MouseArea {
25 id: root
26
27 property bool active: false
28 readonly property alias aboutToDismiss: dismissControls.running
29 default property alias controls: contents.children
30 readonly property bool fullVisible: (spacer.height === 0)
31
32 function dismiss()
33 {
34 dismissControls.restart()
35 }
36
37 function abortDismiss()
38 {
39 dismissControls.stop()
40 active = true
41 }
42
43 onActiveChanged: dismissControls.stop()
44
45 hoverEnabled: true
46 onExited: dismiss()
47 onEntered: {
48 abortDismiss()
49 active = true
50 }
51
52 Timer {
53 id: dismissControls
54
55 running: false
56 interval: 3000
57 repeat: false
58 onTriggered: root.active = false
59 }
60
61 Column {
62 anchors.fill: parent
63 Item {
64 id: spacer
65 anchors {
66 left: parent.left
67 right: parent.right
68 }
69 height: root.active ? 0 : contents.height
70 }
71 Item {
72 id: contents
73 anchors {
74 left: parent.left
75 right: parent.right
76 }
77 height: childrenRect.height
78 }
79 }
80
81 states: [
82 State {
83 name: "active"
84 when: root.active
85 PropertyChanges {
86 target: spacer
87 height: 0
88 enabled: false
89 }
90 },
91 State {
92 name: "deActive"
93 when: !root.active
94 PropertyChanges {
95 target: spacer
96 height: contents.height
97 enabled: true
98 }
99 }
100 ]
101
102 transitions: [
103 Transition {
104 from: "deActive"
105 to: "active"
106 UbuntuNumberAnimation {
107 target: spacer
108 property: "height"
109 duration: UbuntuAnimation.FastDuration
110 }
111 },
112 Transition {
113 from: "active"
114 to: "deActive"
115 UbuntuNumberAnimation {
116 target: spacer
117 property: "height"
118 duration: UbuntuAnimation.SlowDuration
119 }
120 }
121 ]
122}
0123
=== modified file 'src/qml/player/VideoPlayer.qml'
--- src/qml/player/VideoPlayer.qml 2015-05-01 20:10:32 +0000
+++ src/qml/player/VideoPlayer.qml 2016-01-26 22:05:58 +0000
@@ -78,7 +78,8 @@
78// videoOutput: player.videoOutput78// videoOutput: player.videoOutput
79// }79// }
8080
81 GenericToolbar {81
82 ToolBar {
82 id: _controls83 id: _controls
8384
84 objectName: "toolbar"85 objectName: "toolbar"
@@ -89,11 +90,48 @@
89 }90 }
9091
91 height: _controlsContents.height92 height: _controlsContents.height
92
93 Controls {93 Controls {
94 id: _controlsContents94 id: _controlsContents
9595
96 property bool isPaused: false96 property bool wasPausedBeforeSeek: false
97 property bool wasVisibleBeforeSeek: false
98 property int seekPosition: 0
99
100 function aboutToSeek()
101 {
102 wasPausedBeforeSeek = (state == "paused")
103 wasVisibleBeforeSeek = _controls.active && !_controls.aboutToDismiss
104 _controls.abortDismiss()
105 player.pause()
106 _controls.active = true
107 _controlsContents.seekPosition = video.position
108 }
109
110 function seekDone()
111 {
112 // Only automatically resume playing after a seek that is not to the
113 // end of stream (i.e. position == duration)
114 if (player.status !== MediaPlayer.EndOfMedia && !_controlsContents.wasPausedBeforeSeek) {
115 player.play()
116 }
117
118 if (!_controlsContents.wasVisibleBeforeSeek) {
119 _controls.dismiss()
120 }
121
122 _controlsContents.seekPosition = -1
123 _controlsContents.wasPausedBeforeSeek = false
124 _controlsContents.wasVisibleBeforeSeek = false
125 }
126
127 function seek(time)
128 {
129 //keep trak of last seek position in case of the last seek does not complete in time
130 //sometimes the seek is too fast and we can not rely on the video position to calculate
131 //the next seek position.
132 _controlsContents.seekPosition = time
133 player.video.seek(time)
134 }
97135
98 settingsEnabled: mpApplication.desktopMode136 settingsEnabled: mpApplication.desktopMode
99137
@@ -120,22 +158,9 @@
120 }158 }
121 }159 }
122160
123 onSeekRequested: {161 onStartSeek: aboutToSeek()
124 player.video.seek(time)162 onEndSeek: seekDone()
125 }163 onSeekRequested: seek(time)
126
127 onStartSeek: {
128 isPaused = (state == "paused")
129 player.pause()
130 }
131
132 onEndSeek: {
133 // Only automatically resume playing after a seek that is not to the
134 // end of stream (i.e. position == duration)
135 if (player.status != MediaPlayer.EndOfMedia && !isPaused) {
136 player.play()
137 }
138 }
139164
140 onSettingsClicked: {165 onSettingsClicked: {
141 if (mpApplication.desktopMode) {166 if (mpApplication.desktopMode) {
@@ -150,17 +175,34 @@
150 }175 }
151176
152 MouseArea {177 MouseArea {
153 id: _mouseArea178 id: _mouseArea
154179
155 objectName: "videoMouseArea"180 objectName: "videoMouseArea"
156 anchors {181 anchors {
157 left: parent.left182 left: parent.left
158 right: parent.right183 right: parent.right
159 top: parent.top184 top: parent.top
160 bottom: _controls.top185 bottom: _controls.top
161 }186 }
162187
163 onClicked: _controls.active = !_controls.active188 onClicked: _controls.active = !_controls.active
189 }
190
191
192 Keys.onReleased:
193 {
194 if (event.isAutoRepeat) {
195 return
196 }
197
198 switch(event.key) {
199 case Qt.Key_Right:
200 case Qt.Key_Left:
201 _controlsContents.seekDone()
202 break;
203 default:
204 break
205 }
164 }206 }
165207
166 Keys.onPressed: {208 Keys.onPressed: {
@@ -171,9 +213,17 @@
171 case Qt.Key_Right:213 case Qt.Key_Right:
172 case Qt.Key_Left:214 case Qt.Key_Left:
173 {215 {
174 var currentPos = (video ? video.position : 0)216 if (!event.isAutoRepeat) {
175 var nextPos = currentPos217 _controlsContents.aboutToSeek()
176 if (event.key == Qt.Key_Right) {218 }
219 // wait controls be fully visbile
220 if (!_controls.fullVisible)
221 return
222
223 var nextPos = _controlsContents.seekPosition >= 0 ?
224 _controlsContents.seekPosition : 0
225
226 if (event.key === Qt.Key_Right) {
177 var maxPos = (video ? video.duration : 0)227 var maxPos = (video ? video.duration : 0)
178 nextPos += player.seekStep228 nextPos += player.seekStep
179 if (nextPos > maxPos) {229 if (nextPos > maxPos) {
@@ -186,8 +236,8 @@
186 }236 }
187 }237 }
188238
189 if (nextPos != -1) {239 if (nextPos !== -1) {
190 player.video.seek(nextPos)240 _controlsContents.seek(nextPos)
191 }241 }
192 break;242 break;
193 }243 }

Subscribers

People subscribed via source and target branches