diff --git a/app/index.html b/app/index.html index 8aabe10b..45dd1e44 100644 --- a/app/index.html +++ b/app/index.html @@ -209,7 +209,9 @@

+ + @@ -240,6 +242,7 @@

+ diff --git a/app/public/js/common/SC2apiService.js b/app/public/js/common/SC2apiService.js new file mode 100644 index 00000000..aedbd395 --- /dev/null +++ b/app/public/js/common/SC2apiService.js @@ -0,0 +1,136 @@ +'use strict'; + +// Service to work with SoundCloud API v2 +// Note: v2 API is not officially supported by SoundCloud yet, +// be careful using these methods, because they might stop working at any moment + +app.service('SC2apiService', function ( + $rootScope, + $window, + $http, + $q, + rateLimit +) { + + /** + * Soundcloud v2 API endpoint + * @type {String} + */ + var SOUNDCLOUD_API_V2 = 'https://api-v2.soundcloud.com/'; + + /** + * Store URL of the next page, when acessing resource supporting pagination + * @type {String} + */ + var nextPageUrl = ''; + + + // Public API + + /** + * Get current user stream + * @return {promise} + */ + this.getStream = function () { + var params = { + limit: 30 + }; + return sendRequest('stream', { params: params }) + .then(onResponseSuccess) + .then(updateNextPageUrl) + .catch(onResponseError); + }; + + /** + * Get next page for last requested resource + * @return {promise} + */ + this.getNextPage = function () { + if (!nextPageUrl) { + return $q.reject('No next page URL'); + } + return sendRequest(nextPageUrl) + .then(onResponseSuccess) + .then(updateNextPageUrl) + .catch(onResponseError); + }; + + /** + * Get extended information about particular tracks + * @param {array} ids - tracks ids + * @return {promise} + */ + this.getTracksByIds = function (ids) { + var params = { + urns: ids.map(function (trackId) { + return 'soundcloud:tracks:' + trackId.toString(); + }).join(',') + }; + return sendRequest('tracks', { params: params }) + .then(onResponseSuccess) + .catch(onResponseError); + }; + + // Private methods + + /** + * Utility method to send http request + * @param {resource} resource - url part with resource name + * @param {object} config - options for $http + * @param {object} options - custom options (show loading, etc) + * @return {promise} + */ + function sendRequest(resource, config, options) { + config = config || {}; + // Check if passed absolute url + if (resource.indexOf('http') === 0) { + config.url = resource; + } else { + config.url = SOUNDCLOUD_API_V2 + resource; + } + config.params = config.params || {}; + config.params.oauth_token = $window.scAccessToken; + + options = options || {}; + if (options.loading !== false) { + $rootScope.isLoading = true; + } + + return $http(config); + } + + /** + * Response success handler + * @param {object} response - $http response object + * @return {object} - response data + */ + function onResponseSuccess(response) { + if (response.status !== 200) { + return $q.reject(response.data); + } + return response.data; + } + + /** + * Response error handler + * @param {object} response - $http response object + * @return {promise} + */ + function onResponseError(response) { + if (response.status === 429) { + rateLimit.showNotification(); + } + return $q.reject(response.data); + } + + /** + * Update value of the next page for paginatable resources + * @param {data} data - response data + * @return {object} - pass through data to use function in promise chain + */ + function updateNextPageUrl(data) { + nextPageUrl = data.next_href || ''; + return data; + } + +}); diff --git a/app/public/js/common/SCapiService.js b/app/public/js/common/SCapiService.js index 49c95419..d000bb6f 100644 --- a/app/public/js/common/SCapiService.js +++ b/app/public/js/common/SCapiService.js @@ -1,35 +1,15 @@ 'use strict'; -app.service('SCapiService', function ($http, $window, $q, $log, $state, $stateParams, $rootScope, ngDialog) { - - function rateLimitReached() { - ngDialog.open({ - showClose: false, - template: 'views/common/modal.html', - controller: ['$scope', function ($scope) { - var urlGH = 'https://api.github.com/repos/Soundnode/soundnode-about/contents/rate-limit-reached.html'; - var config = { - headers: { - 'Accept': 'application/vnd.github.v3.raw+json' - } - }; - - $scope.content = ''; - - $scope.closeModal = function () { - ngDialog.closeAll(); - }; - - $http.get(urlGH, config) - .success(function (data) { - $scope.content = data; - }) - .error(function (error) { - console.log('Error', error) - }); - }] - }); - } +app.service('SCapiService', function ( + $http, + $window, + $q, + $log, + $state, + $stateParams, + $rootScope, + rateLimit +) { /** * Responsible to store next url for pagination request @@ -56,7 +36,7 @@ app.service('SCapiService', function ($http, $window, $q, $log, $state, $statePa return $http.get(url) .then(function (response, status) { if (response.status === 429) { - rateLimitReached(); + rateLimit.showNotification(); return []; } @@ -419,5 +399,71 @@ app.service('SCapiService', function ($http, $window, $q, $log, $state, $statePa }); }; + /** + * Get ids of all user reposts + * @param {string} next_href - next page of ids, coming from recursive call + * @return {promise} + */ + this.getRepostsIds = function(next_href) { + var url = next_href || 'https://api.soundcloud.com/e1/me/track_reposts/ids?linked_partitioning=1&limit=200&oauth_token=' + $window.scAccessToken; + var that = this; + return $http.get(url) + .then(function (response) { + if (!angular.isObject(response.data)) { + return $q.reject(response.data); + } + if (response.data.hasOwnProperty('next_href')) { + // call itself to get all repost ids + return that.getRepostsIds(response.data.next_href) + .then(function (next_href_response) { + // sums all ids + return response.data.collection.concat(next_href_response.collection); + }); + } + return response.data.collection; + + }) + .catch(function (response) { + return $q.reject(response.data); + }); + }; + + /** + * Create repost for a track + * @param {string} songId + * @return {promise} + */ + this.createRepost = function (songId) { + var url = 'https://api.soundcloud.com/e1/me/track_reposts/' + songId + '?&oauth_token=' + $window.scAccessToken; + return $http.put(url) + .then(function (response) { + if (!angular.isObject(response.data)) { + return $q.reject(response.data); + } + return response.data; + }) + .catch(function (response) { + return $q.reject(response.data); + }); + }; + + /** + * Delete repost for a track + * @param {string} songId + * @return {promise} + */ + this.deleteRepost = function (songId) { + var url = 'https://api.soundcloud.com/e1/me/track_reposts/' + songId + '?&oauth_token=' + $window.scAccessToken; + return $http.delete(url) + .then(function (response) { + if (!angular.isObject(response.data)) { + return $q.reject(response.data); + } + return response.data; + }) + .catch(function (response) { + return $q.reject(response.data); + }); + }; }); diff --git a/app/public/js/common/favoriteSongDirective.js b/app/public/js/common/favoriteSongDirective.js index f5c003b4..185774c5 100644 --- a/app/public/js/common/favoriteSongDirective.js +++ b/app/public/js/common/favoriteSongDirective.js @@ -2,11 +2,7 @@ app.directive('favoriteSong', function( $rootScope, - $log, SCapiService, - $timeout, - $state, - $stateParams, notificationFactory ) { return { diff --git a/app/public/js/common/queueCtrl.js b/app/public/js/common/queueCtrl.js index 926f5276..8df87291 100644 --- a/app/public/js/common/queueCtrl.js +++ b/app/public/js/common/queueCtrl.js @@ -90,6 +90,27 @@ app.controller('QueueCtrl', function( }); }; + /** + * repost track from queue + * @param {object} $event - Angular event + * @return {promise} + */ + $scope.repost = function ($event) { + var trackData = $($event.target).closest('.queueListView_list_item').data(); + var songId = trackData.songId; + + return SCapiService.createRepost(songId) + .then(function (status) { + if (angular.isObject(status)) { + notificationFactory.success('Song added to reposts!'); + utilsService.markTrackAsReposted(songId); + } + }) + .catch(function (status) { + notificationFactory.error('Something went wrong!'); + }); + }; + /** * add track to playlist * @param $event diff --git a/app/public/js/common/rateLimitService.js b/app/public/js/common/rateLimitService.js new file mode 100644 index 00000000..481936a6 --- /dev/null +++ b/app/public/js/common/rateLimitService.js @@ -0,0 +1,37 @@ +'use strict'; + +// Displays popup with a warning that rate limit is reached, with a link +// to SoundCloud docs attached. Call it when response returns 429 status +app.service('rateLimit', function ( + $http, + ngDialog +) { + this.showNotification = function () { + return ngDialog.open({ + showClose: false, + template: 'views/common/modal.html', + controller: ['$scope', function ($scope) { + var urlGH = 'https://api.github.com/repos/Soundnode/soundnode-about/contents/rate-limit-reached.html'; + var config = { + headers: { + 'Accept': 'application/vnd.github.v3.raw+json' + } + }; + + $scope.content = ''; + + $scope.closeModal = function () { + ngDialog.closeAll(); + }; + + $http.get(urlGH, config) + .then(function (response) { + $scope.content = response.data; + }) + .catch(function (response) { + console.log('Error', response.data); + }); + }] + }); + }; +}); diff --git a/app/public/js/common/repostedSongDirective.js b/app/public/js/common/repostedSongDirective.js new file mode 100644 index 00000000..37ae08b7 --- /dev/null +++ b/app/public/js/common/repostedSongDirective.js @@ -0,0 +1,51 @@ +'use strict'; + +app.directive('repostedSong', function ( + $rootScope, + SCapiService, + notificationFactory +) { + return { + restrict: 'A', + scope: { + reposted: '=' + }, + link: function ($scope, elem, attrs) { + var songId; + + elem.bind('click', function () { + songId = attrs.songId; + + if (this.classList.contains('reposted')) { + + SCapiService.deleteRepost(songId) + .then(function (status) { + if (angular.isObject(status)) { + notificationFactory.success('Song removed from reposts!'); + $scope.reposted = false; + } + }) + .catch(function () { + notificationFactory.error('Something went wrong!'); + }); + + } else { + + SCapiService.createRepost(songId) + .then(function (status) { + if (angular.isObject(status)) { + notificationFactory.success('Song added to reposts!'); + $scope.reposted = true; + } + }) + .catch(function () { + notificationFactory.error('Something went wrong!'); + }); + + } + + }); + + } + }; +}); \ No newline at end of file diff --git a/app/public/js/common/tracksDirective.js b/app/public/js/common/tracksDirective.js index 99b49a08..2b5d51eb 100644 --- a/app/public/js/common/tracksDirective.js +++ b/app/public/js/common/tracksDirective.js @@ -3,7 +3,11 @@ app.directive('tracks', function () { return { restrict: 'AE', - scope: { data: '=' }, + scope: { + data: '=', + user: '=', + type: '=' + }, templateUrl: "views/common/tracks.html" - } + }; }); \ No newline at end of file diff --git a/app/public/js/common/utilsService.js b/app/public/js/common/utilsService.js index b8f2bd3b..3a15ca6a 100644 --- a/app/public/js/common/utilsService.js +++ b/app/public/js/common/utilsService.js @@ -1,14 +1,27 @@ 'use strict'; app.factory('utilsService', function( - queueService + queueService, + SCapiService, + $q ) { /** * API (helpers/utils) to interact with the UI * and the rest of the App * @type {{}} */ - var Utils = {}; + var Utils = { + /** + * Store cache of fetched likes ids + * @type {Array} + */ + likesIds: [], + /** + * Store cache of fetched reposts ids + * @type {Array} + */ + repostsIds: [] + }; /** * Find track and mark as favorited @@ -16,11 +29,21 @@ app.factory('utilsService', function( * @method markTrackAsFavorite */ Utils.markTrackAsFavorite = function(trackId) { - var track = document.querySelector('a[data-song-id="' + trackId + '"]'); + var track = document.querySelector('a[favorite-song][data-song-id="' + trackId + '"]'); track.classList.add('liked'); //track.setAttribute('favorite', true); }; + /** + * Find track and mark as reposted + * @param trackId (track id) + * @method markTrackAsReposted + */ + Utils.markTrackAsReposted = function(trackId) { + var track = document.querySelector('a[reposted-song][data-song-id="' + trackId + '"]'); + track.classList.add('reposted'); + }; + /** * Activate track in view based on trackId * @param trackId [contain track id] @@ -84,6 +107,51 @@ app.factory('utilsService', function( return list; }; + /** + * Fetch ids of liked tracks and apply them to existing collection + * @param {array} collection - stream collection or tracks array + * @param {boolean} fromCache - if should make request to API + * @return {promise} - promise with original collection + */ + Utils.updateTracksLikes = function (collection, fromCache) { + var fetchLikedIds = fromCache ? + $q(function (resolve) { resolve(Utils.likesIds) }) : + SCapiService.getFavoritesIds(); + return fetchLikedIds.then(function (ids) { + if (!fromCache) { + Utils.likesIds = ids; + } + collection.forEach(function (item) { + var track = item.track || item; + // modify each track by reference + track.user_favorite = Utils.likesIds.indexOf(track.id) > -1; + }); + return collection; + }); + }; + + /** + * Fetch ids of reposted tracks and apply them to existing collection + * @param {array} collection - stream collection or tracks array + * @param {boolean} fromCache - if should make request to API + * @return {promise} - promise with original collection + */ + Utils.updateTracksReposts = function (collection, fromCache) { + var fetchRepostsIds = fromCache ? + $q(function (resolve) { resolve(Utils.repostsIds) }) : + SCapiService.getRepostsIds(); + return fetchRepostsIds.then(function (ids) { + if (!fromCache) { + Utils.repostsIds = ids; + } + collection.forEach(function (item) { + var track = item.track || item; + // modify each track by reference + track.user_reposted = Utils.repostsIds.indexOf(track.id) > -1; + }); + return collection; + }); + }; return Utils; diff --git a/app/public/js/favorites/favoritesCtrl.js b/app/public/js/favorites/favoritesCtrl.js index 64716900..d22e5533 100644 --- a/app/public/js/favorites/favoritesCtrl.js +++ b/app/public/js/favorites/favoritesCtrl.js @@ -1,6 +1,11 @@ 'use strict'; -app.controller('FavoritesCtrl', function ($scope, SCapiService, $rootScope) { +app.controller('FavoritesCtrl', function ( + $scope, + $rootScope, + SCapiService, + utilsService +) { var endpoint = 'me/favorites' , params = 'linked_partitioning=1'; @@ -14,6 +19,7 @@ app.controller('FavoritesCtrl', function ($scope, SCapiService, $rootScope) { }, function(error) { console.log('error', error); }).finally(function() { + utilsService.updateTracksReposts($scope.data); $rootScope.isLoading = false; }); @@ -28,6 +34,7 @@ app.controller('FavoritesCtrl', function ($scope, SCapiService, $rootScope) { for ( var i = 0; i < data.collection.length; i++ ) { $scope.data.push( data.collection[i] ) } + utilsService.updateTracksReposts(data.collection, true); }, function(error) { console.log('error', error); }).finally(function(){ diff --git a/app/public/js/profile/profileCtrl.js b/app/public/js/profile/profileCtrl.js index 7561e8ae..f62fae4d 100644 --- a/app/public/js/profile/profileCtrl.js +++ b/app/public/js/profile/profileCtrl.js @@ -4,7 +4,13 @@ 'use strict' -app.controller('ProfileCtrl', function ($scope, SCapiService, $rootScope, $stateParams) { +app.controller('ProfileCtrl', function ( + $scope, + $rootScope, + $stateParams, + SCapiService, + utilsService +) { //ctrl variables var userId = $stateParams.id; @@ -36,6 +42,7 @@ app.controller('ProfileCtrl', function ($scope, SCapiService, $rootScope, $state }, function(error) { console.log('error', error); }).finally(function() { + utilsService.updateTracksReposts($scope.data); $rootScope.isLoading = false; }); @@ -60,6 +67,7 @@ app.controller('ProfileCtrl', function ($scope, SCapiService, $rootScope, $state for ( var i = 0; i < data.collection.length; i++ ) { $scope.data.push( data.collection[i] ) } + utilsService.updateTracksReposts(data.collection, true); }, function(error) { console.log('error', error); }).finally(function(){ diff --git a/app/public/js/search/searchCtrl.js b/app/public/js/search/searchCtrl.js index 08b5f1be..83d717fe 100644 --- a/app/public/js/search/searchCtrl.js +++ b/app/public/js/search/searchCtrl.js @@ -1,6 +1,13 @@ 'use strict'; -app.controller('searchCtrl', function ($scope, $http, $stateParams, SCapiService, $rootScope) { +app.controller('searchCtrl', function ( + $scope, + $rootScope, + $http, + $stateParams, + SCapiService, + utilsService +) { $scope.title = 'Results for: ' + $stateParams.q; $scope.data = ''; @@ -12,6 +19,7 @@ app.controller('searchCtrl', function ($scope, $http, $stateParams, SCapiService }, function(error) { console.log('error', error); }).finally(function(){ + utilsService.updateTracksReposts($scope.data); $rootScope.isLoading = false; }); @@ -26,6 +34,7 @@ app.controller('searchCtrl', function ($scope, $http, $stateParams, SCapiService for ( var i = 0; i < data.collection.length; i++ ) { $scope.data.push( data.collection[i] ) } + utilsService.updateTracksReposts(data.collection, true); }, function(error) { console.log('error', error); }).finally(function(){ diff --git a/app/public/js/stream/streamCtrl.js b/app/public/js/stream/streamCtrl.js index a479fbb8..5a67a921 100644 --- a/app/public/js/stream/streamCtrl.js +++ b/app/public/js/stream/streamCtrl.js @@ -2,35 +2,31 @@ app.controller('StreamCtrl', function ( $scope, + $rootScope, SCapiService, - $rootScope + SC2apiService, + utilsService ) { - var endpoint = 'me/activities' - , params = 'limit=33' - , tracksIds = []; + var tracksIds = []; $scope.title = 'Stream'; $scope.data = ''; $scope.busy = false; - $scope.likes = ''; - SCapiService.get(endpoint, params) - .then(function(data) { - var tracks = filterTracks(data.collection); - $scope.data = tracks; - }, function(error) { - console.log('error', error); - }).finally(function() { + SC2apiService.getStream() + .then(filterCollection) + .then(function (collection) { + $scope.data = collection; - SCapiService.getFavoritesIds() - .then(function(data) { - $scope.likes = data; - markLikedTracks($scope.data); - }, function(error) { - console.log('error', error); - }).finally(function() { - $rootScope.isLoading = false; - }); + loadTracksInfo(collection); + }) + .catch(function (error) { + console.log('error', error); + }) + .finally(function () { + utilsService.updateTracksLikes($scope.data); + utilsService.updateTracksReposts($scope.data); + $rootScope.isLoading = false; }); $scope.loadMore = function() { @@ -39,38 +35,67 @@ app.controller('StreamCtrl', function ( } $scope.busy = true; - SCapiService.getNextPage() - .then(function(data) { - var tracks = filterTracks(data.collection); - markLikedTracks(tracks); - $scope.data = $scope.data.concat(tracks); - }, function(error) { + SC2apiService.getNextPage() + .then(filterCollection) + .then(function (collection) { + $scope.data = $scope.data.concat(collection); + utilsService.updateTracksLikes(collection, true); + utilsService.updateTracksReposts(collection, true); + loadTracksInfo(collection); + }, function (error) { console.log('error', error); - }).finally(function(){ + }).finally(function () { $scope.busy = false; $rootScope.isLoading = false; }); }; - function filterTracks(tracks) { - // Filter reposts: display only first appearance of track in stream - return tracks.filter(function (track) { - var exists = tracksIds.indexOf(track.origin.id) > -1; - if (!exists) { - tracksIds.push(track.origin.id); + function filterCollection(data) { + return data.collection.filter(function (item) { + // Keep only tracks (remove playlists, etc) + var isTrackType = item.type === 'track' || + item.type === 'track-repost' || + !!(item.track && item.track.streamable); + if (!isTrackType) { + return false; } - return !exists; + + // Filter reposts: display only first appearance of track in stream + var exists = tracksIds.indexOf(item.track.id) > -1; + if (exists) { + return false; + } + + // "stream_url" property is missing in V2 API + item.track.stream_url = item.track.uri + '/stream'; + + tracksIds.push(item.track.id); + return true; }); } - function markLikedTracks (tracks) { - var tracksData = tracks.collection || tracks; - for (var i = 0; i < tracksData.length; ++i) { - var track = tracksData[i].origin; + // Load extra information, because SoundCloud v2 API does not return + // number of track likes + function loadTracksInfo(collection) { + var ids = collection.map(function (item) { + return item.track.id; + }); - if (track.hasOwnProperty('user_favorite')) - track.user_favorite = ($scope.likes.indexOf(track.id) != -1); - } + SC2apiService.getTracksByIds(ids) + .then(function (tracks) { + // Both collections are unordered + collection.forEach(function (item) { + tracks.forEach(function (track) { + if (item.track.id === track.id) { + item.track.favoritings_count = track.likes_count; + return; + } + }); + }); + }) + .catch(function (error) { + console.log('error', error); + }); } }); \ No newline at end of file diff --git a/app/public/js/tag/tagCtrl.js b/app/public/js/tag/tagCtrl.js index 35b22099..db64161e 100644 --- a/app/public/js/tag/tagCtrl.js +++ b/app/public/js/tag/tagCtrl.js @@ -1,6 +1,13 @@ 'use strict'; -app.controller('tagCtrl', function ($scope, $http, $stateParams, SCapiService, $rootScope) { +app.controller('tagCtrl', function ( + $scope, + $rootScope, + $http, + $stateParams, + SCapiService, + utilsService +) { var tagUrl = encodeURIComponent($stateParams.name); $scope.tag = $stateParams.name; @@ -12,11 +19,12 @@ app.controller('tagCtrl', function ($scope, $http, $stateParams, SCapiService, $ }, function (error) { console.log('error', error); }).finally(function () { + utilsService.updateTracksReposts($scope.data); $rootScope.isLoading = false; }); $scope.loadMore = function () { - if ( $scope.busy || !SCapiService.isNextPage()) { + if ( $scope.busy || !SCapiService.next_page) { return; } $scope.busy = true; @@ -26,6 +34,7 @@ app.controller('tagCtrl', function ($scope, $http, $stateParams, SCapiService, $ for (var i = 0; i < data.collection.length; i++) { $scope.data.push(data.collection[i]) } + utilsService.updateTracksReposts(data.collection, true); }, function (error) { console.log('error', error); }).finally(function () { diff --git a/app/public/stylesheets/sass/_components/_playlist-songs.scss b/app/public/stylesheets/sass/_components/_playlist-songs.scss index 8b81c09d..04627bc7 100644 --- a/app/public/stylesheets/sass/_components/_playlist-songs.scss +++ b/app/public/stylesheets/sass/_components/_playlist-songs.scss @@ -20,6 +20,8 @@ display: block; margin: 0 0 10px 0; cursor: pointer; + width: 100%; + float: none; & span { cursor: pointer; diff --git a/app/public/stylesheets/sass/_components/_songlist.scss b/app/public/stylesheets/sass/_components/_songlist.scss index c82bc703..7607ccaf 100644 --- a/app/public/stylesheets/sass/_components/_songlist.scss +++ b/app/public/stylesheets/sass/_components/_songlist.scss @@ -88,15 +88,33 @@ div.active { } } -.songList_item_song_user { +.songList_item_song_info { display: block; color: $defaultColor; font-size: 11px; text-transform: uppercase; + +} + +.songList_item_song_user { + width: 165px; + float: left; +} + +.songList_item_repost { + & > i { + color: inherit; + cursor: default; + } + & > a { + font-size: 8px; + } } .songList_item_song_length { float: right; + width: 45px; + text-align: right } .songList_item_song_tit { @@ -149,7 +167,8 @@ div.active { margin-right: 0; } - &.liked { + &.liked, + &.reposted { & > .fa { color: $scColor; } diff --git a/app/views/common/queueList.html b/app/views/common/queueList.html index 14e7c956..d4209459 100644 --- a/app/views/common/queueList.html +++ b/app/views/common/queueList.html @@ -28,6 +28,7 @@ diff --git a/app/views/common/tracks.html b/app/views/common/tracks.html index 8708e6a7..62f8b228 100644 --- a/app/views/common/tracks.html +++ b/app/views/common/tracks.html @@ -32,9 +32,21 @@

{{ data.title }}

-

- {{ data.user.username }} - {{ formatSongDuration (data.duration) }} +

+ +
+ {{ formatSongDuration (data.duration) }} +

@@ -42,6 +54,9 @@

+ + +

diff --git a/app/views/stream/stream.html b/app/views/stream/stream.html index 557f9a67..7ef3d268 100644 --- a/app/views/stream/stream.html +++ b/app/views/stream/stream.html @@ -1,18 +1,17 @@

{{ title }}

- +
-
    -
  • +
  • - +