Built entirely with...
First commit: April 9, 2016
iOS: primary focus.
Android: Not in original 1.0 release plan.
Decided to try it Aug. 6th.
Ready and submitted to App Store by July 6, 2016
< 3 months part time!
The Dream
July 6th, 2016
APP STORE REJECTED
REASON: Spotify IPv6 Support
New App Store Policy June 1st - What are the odds?!!
REWIND!
Plugins
Native API's
Native Audio
CoreGraphics
Realtime Audio Waveform
Spotify Native SDK's
Toys
Toys
Toys
Toys
Toys
Toys
nativescript-spotify
Musical Toys
export class SpotifyDemo extends Observable {
private _spotify: TNSSpotifyPlayer;
constructor() {
super();
this._spotify = new TNSSpotifyPlayer();
// when using iOS delegates that extend NSObject, TypeScript constructors are not used, therefore a separate `initPlayer()` exists
this._spotify.initPlayer(true); // passing `true` lets player know you want it to emit events (sometimes it's not desired)
// small sample of events (see Documentation below for full list)
this._spotify.events.on('albumArtChange', (eventData) => {
this.updateAlbumArt(eventData.data.url);
});
this._spotify.auth.events.on('authLoginSuccess', (eventData) => {
this.loginSuccess();
});
}
public login() {
TNSSpotifyAuth.LOGIN();
}
public play(args?: EventData) {
this._spotify.togglePlay('spotify:track:58s6EuEYJdlb0kO7awm3Vp').then((isPlaying: boolean) => {
console.log(isPlaying ? 'Playing!' : 'Paused!');
}, (error) => {
console.log(`Playback error: ${error}`);
});
}
private updateAlbumArt(url: string) {
this.set(`currentAlbumUrl`, url);
}
private loginSuccess() {
console.log(`loginSuccess!`);
}
}
Easy API to work with...
iOS Player
export class TNSSpotifyPlayer extends NSObject {
public static ObjCProtocols = [SPTAudioStreamingDelegate, SPTAudioStreamingPlaybackDelegate];
public player: any; // SPTAudioStreamingController
public initPlayer(emitEvents?: boolean) {
// init auth session
TNSSpotifyAuth.VERIFY_SESSION().then(() => {
this.setLoggedIn(true);
}, () => {
this.setLoggedIn(false);
});
}
public togglePlay(track?: string, force?: boolean): Promise<any> {
return new Promise((resolve, reject) => {
if (track && (track !== this._loadedTrack)) {
// first time play or changing track
this.play(track).then(resolve, reject);
} else if (this.player) {
// toggling
let playState = typeof force !== 'undefined' ? force : !this.player.isPlaying;
this.player.setIsPlayingCallback(playState, (error) => {
if (error != null) {
console.log(`*** Pause/Resume playback got error:`);
console.log(error);
// try to verify/renew the session
TNSSpotifyAuth.VERIFY_SESSION(TNSSpotifyAuth.SESSION).then(() => {
let origResolve = resolve;
this.togglePlay(track, force).then((isPlaying: boolean) => {
origResolve(isPlaying);
});
}, () => {
if (this.isLoginError(error.localizedDescription)) {
this.loginError();
reject('login');
} else {
reject(false);
}
});
return;
}
resolve(this.player.isPlaying);
});
}
});
}
public setVolume(val: number): Promise<any> {
return new Promise((resolve, reject) => {
if (this.player) {
this.player.setVolumeCallback(val, (error) => {
if (error !== null) {
console.log(`Spotify Player volume adjust error:`, error);
reject(error);
return;
}
resolve();
});
} else {
reject();
}
});
}
// Delegate methods
public audioStreamingDidChangePlaybackStatus(controller: any, playing: boolean) {
console.log(`DidChangePlaybackStatus: ${playing}`);
if (this.events) {
this._changedPlaybackStatus.data.playing = playing;
this.events.notify(this._changedPlaybackStatus);
}
}
public audioStreamingDidChangePlaybackState(controller: any, state: any) {
console.log(`DidChangePlaybackState: ${state}`);
if (this.events) {
this._changedPlaybackState.data.state = state;
this.events.notify(this._changedPlaybackState);
this.updateCoverArt(state.currentTrack.albumUri);
// this.updateCoverArt(state.currentTrack.albumCoverArtUri);
}
}
public audioStreamingDidSeekToOffset(controller: any, offset: any) {
console.log(`DidSeekToOffset: ${offset}`);
if (this.events) {
this._seekedToOffset.data.offset = offset;
this.events.notify(this._seekedToOffset);
}
}
public audioStreamingDidChangeVolume(controller: any, volume: any) {
console.log(`DidChangeVolume: ${volume}`);
if (this.events) {
this._changedVolume.data.volume = volume;
this.events.notify(this._changedVolume);
}
}
public audioStreamingDidChangeShuffleStatus(controller: any, isShuffled: boolean) {
console.log(`DidChangeShuffleStatus: ${isShuffled}`);
if (this.events) {
this._changedShuffleStatus.data.shuffle = isShuffled;
this.events.notify(this._changedShuffleStatus);
}
}
public audioStreamingDidChangeRepeatStatus(controller: any, isRepeated: boolean) {
console.log(`DidChangeRepeatStatus: ${isRepeated}`);
if (this.events) {
this._changedRepeatStatus.data.repeat = isRepeated;
this.events.notify(this._changedRepeatStatus);
}
}
public audioStreamingDidChangeToTrack(controller: any, trackMetadata: NSDictionary) {
console.log(`DidChangeToTrack: ${trackMetadata}`);
if (this.events) {
this._changedToTrack.data.metadata = trackMetadata;
this.events.notify(this._changedToTrack);
}
}
public audioStreamingDidFailToPlayTrack(controller: any, trackUri: NSURL) {
console.log(`DidFailToPlayTrack: ${trackUri.absoluteString}`);
if (this.events) {
this._failedToPlayTrack.data.url = trackUri.absoluteString;
this.events.notify(this._failedToPlayTrack);
}
}
public audioStreamingDidStartPlayingTrack(controller: any, trackUri: NSURL) {
console.log(`DidStartPlayingTrack: ${trackUri.absoluteString}`);
this.updateCoverArt(this.currentTrackMetadata().albumUri);
if (this.events) {
this._startedPlayingTrack.data.url = trackUri.absoluteString;
this.events.notify(this._startedPlayingTrack);
}
}
public audioStreamingDidStopPlayingTrack(controller: any, trackUri: NSURL) {
console.log(`DidStopPlayingTrack: ${trackUri.absoluteString}`);
if (this.events) {
this._stoppedPlayingTrack.data.url = trackUri.absoluteString;
this.events.notify(this._stoppedPlayingTrack);
}
}
public audioStreamingDidSkipToNextTrack(controller: any) {
console.log(`DidSkipToNextTrack`);
if (this.events) {
this.events.notify(this._skippedToNextTrack);
}
}
public audioStreamingDidSkipToPreviousTrack(controller: any) {
console.log(`DidSkipToPreviousTrack`);
if (this.events) {
this.events.notify(this._skippedToPreviousTrack);
}
}
public audioStreamingDidBecomeActivePlaybackDevice(controller: any) {
console.log(`DidBecomeActivePlaybackDevice`);
if (this.events) {
this.events.notify(this._activePlaybackDevice);
}
}
public audioStreamingDidBecomeInactivePlaybackDevice(controller: any) {
console.log(`DidBecomeInactivePlaybackDevice`);
if (this.events) {
this.events.notify(this._inactivePlaybackDevice);
}
}
public audioStreamingDidPopQueue(controller: any) {
console.log(`DidPopQueue`);
if (this.events) {
this.events.notify(this._poppedQueue);
}
}
// SPTAudioStreamingDelegate
public audioStreamingDidEncounterTemporaryConnectionError(controller: any) {
console.log(`audioStreamingDidEncounterTemporaryConnectionError`);
if (this.events) {
this.events.notify(this._temporaryConnectionError);
}
}
public audioStreamingDidEncounterError(controller: any, error: any) {
console.log(`audioStreamingDidEncounterTemporaryConnectionError`);
console.log(error);
if (this.events) {
this._streamError.data.error = error;
this.events.notify(this._streamError);
}
}
public audioStreamingDidReceiveMessage(controller: any, message: any) {
console.log(`audioStreamingDidReceiveMessage`);
if (this.events) {
this._receivedMessage.data.message = message;
this.events.notify(this._receivedMessage);
}
}
public audioStreamingDidDisconnect(controller: any) {
console.log(`audioStreamingDidDisconnect`);
if (this.events) {
this.events.notify(this._streamDisconnected);
}
}
public audioStreamingDidLogin() {
console.log(`audioStreamingDidLogin`);
this._playerLoggedIn = true;
let handlePromise = (success) => {
if (this._loggingInPromise) {
if (success) {
this._loggingInPromise.resolve();
} else {
this._loggingInPromise.reject();
}
this._loggingInPromise = undefined;
}
};
// check if user is non-premium
TNSSpotifyAuth.CHECK_PREMIUM().then(() => {
handlePromise(true);
}, () => {
handlePromise(false);
});
}
public audioStreamingDidLogout(controller: any) {
console.log(`audioStreamingDidLogout`);
let errorRef = new interop.Reference();
if (!this.player.stopWithError(errorRef)) {
if (errorRef) {
console.log(`stopWithError:`);
for (let key in errorRef) {
console.log(errorRef[key]);
}
}
}
this.player = undefined;
TNSSpotifyAuth.LOGOUT();
}
private play(track: string): Promise<any> {
return new Promise((resolve, reject) => {
this.checkPlayer().then(() => {
this.playUri(track, resolve, reject);
}, () => {
reject('login');
});
});
}
private playUri(track: string, resolve: Function, reject: Function) {
// https://developer.spotify.com/ios-sdk-docs/Documents/Classes/SPTAudioStreamingController.html
console.log(track);
// this.player.playURICallback(NSURL.URLWithString(track), (error) => {
this.player.playURIStartingWithIndexCallback(NSURL.URLWithString(track), 0, (error) => {
if (error != null) {
console.log(`*** playURICallback got error:`);
console.log(error);
if (this.isLoginError(error.localizedDescription)) {
this.loginError();
reject('login');
} else {
reject(false);
}
return;
}
this._loadedTrack = track;
resolve(true);
});
}
private checkPlayer(): Promise<boolean> {
let printErrorRef = (ref: any, failure: boolean) => {
console.log(`SPTAudioStreamingController.sharedInstance().startWithClientIdError Start${failure ? ' Failure' : ''}:`, ref);
for (let key in ref) {
console.log(ref[key]);
}
};
return new Promise((resolve, reject) => {
if (!this._started) {
let errorRef = new interop.Reference();
this.player = SPTAudioStreamingController.sharedInstance();
// if (this.player.startWithClientIdError(TNSSpotifyConstants.CLIENT_ID, errorRef)) {
if (this.player.startWithClientIdAudioControllerAllowCachingError(TNSSpotifyConstants.CLIENT_ID, null, false, errorRef)) {
this._started = true;
this.player.delegate = this;
this.player.playbackDelegate = this;
// this.player.diskCache = SPTDiskCache.alloc().initWithCapacity(1024 * 1024 * 64);
if (errorRef) {
console.log(errorRef.description);
printErrorRef(errorRef, false);
}
if (!this._playerLoggedIn) {
this._loggingInPromise = {
resolve: resolve,
reject: reject
};
this.player.loginWithAccessToken(TNSSpotifyAuth.SESSION.accessToken);
} else {
TNSSpotifyAuth.CHECK_PREMIUM().then(resolve, reject);
}
} else {
this._started = false;
if (errorRef) {
printErrorRef(errorRef, true);
}
// check if user is non-premium
// since this is real player error, reject both
// but it will at least alert user if they are non-premium still
TNSSpotifyAuth.CHECK_PREMIUM().then(reject, reject);
}
} else {
resolve();
}
});
}
private updateCoverArt(albumUri: string): Promise<any> {
return new Promise((resolve, reject) => {
SPTAlbum.albumWithURISessionCallback(NSURL.URLWithString(albumUri), TNSSpotifyAuth.SESSION, (error, albumObj: any) => {
if (error != null) {
console.log(`*** albumWithURISessionCallback got error:`);
console.log(error);
reject();
return;
}
// albumObj: SPTAlbum = https://developer.spotify.com/ios-sdk-docs/Documents/Classes/SPTAlbum.html
this._currentAlbumImageUrl = albumObj.largestCover.imageURL.absoluteString;
if (this.events) {
this._albumArtChange.data.url = this._currentAlbumImageUrl;
this.events.notify(this._albumArtChange);
}
resolve();
});
});
}
private isLoginError(desc: string): boolean {
if (desc.indexOf('invalid credentials') > -1 || desc.indexOf('NULL') > -1) {
return true;
} else {
return false;
}
}
private loginError() {
this.setLoggedIn(false);
Utils.alert('You need to login to renew your session.');
}
private setLoggedIn(value: boolean) {
this._loggedIn = value;
if (!value) {
this._playerLoggedIn = false;
if (this._started) {
this._started = false;
console.log(`streamingcontroller logout()`);
this.player.logout();
setTimeout(() => {
// if delegate method did not get called, fallback to manual
if (this.player) {
this.audioStreamingDidLogout(null);
}
}, 1000);
}
}
}
private playerReady(): void {
if (this.events) {
this._playerReady.data.loggedIn = this._loggedIn;
this.events.notify(this._playerReady);
}
}
private setupEvents() {
// auth state
this.auth.events.on('authLoginChange', (eventData: any) => {
this.setLoggedIn(eventData.data.status);
});
// player events
this.events = new Observable();
this._albumArtChange = {
eventName: 'albumArtChange',
data: {
url: ''
}
};
this._playerReady = {
eventName: 'playerReady',
data: {
loggedIn: false
}
};
// delegate events
this._changedPlaybackStatus = {
eventName: 'changedPlaybackStatus',
data: {
playing: false
}
};
this._changedPlaybackState = {
eventName: 'changedPlaybackState',
data: {
state: {}
}
};
this._seekedToOffset = {
eventName: 'seekedToOffset',
data: {
offset: 0
}
};
this._changedVolume = {
eventName: 'changedVolume',
data: {
volume: 0
}
};
this._changedShuffleStatus = {
eventName: 'changedShuffleStatus',
data: {
shuffle: false
}
};
this._changedRepeatStatus = {
eventName: 'changedRepeatStatus',
data: {
repeat: false
}
};
this._changedToTrack = {
eventName: 'changedToTrack',
data: {
metadata: null
}
};
this._failedToPlayTrack = {
eventName: 'failedToPlayTrack',
data: {
url: null
}
};
this._startedPlayingTrack = {
eventName: 'startedPlayingTrack',
data: {
url: null
}
};
this._stoppedPlayingTrack = {
eventName: 'stoppedPlayingTrack',
data: {
url: null
}
};
this._skippedToNextTrack = {
eventName: 'skippedToNextTrack'
};
this._skippedToPreviousTrack = {
eventName: 'skippedToPreviousTrack'
};
this._activePlaybackDevice = {
eventName: 'activePlaybackDevice'
};
this._inactivePlaybackDevice = {
eventName: 'inactivePlaybackDevice'
};
this._poppedQueue = {
eventName: 'poppedQueue'
};
this._temporaryConnectionError = {
eventName: 'temporaryConnectionError'
};
this._streamError = {
eventName: 'streamError',
data: {
error: null
}
};
this._receivedMessage = {
eventName: 'receivedMessage',
data: {
message: null
}
};
this._streamDisconnected = {
eventName: 'streamDisconnected'
};
}
}
Android Player
export class TNSSpotifyPlayer {
public player: any;
public initPlayer(emitEvents?: boolean) {
// init auth session
TNSSpotifyAuth.VERIFY_SESSION().then(() => {
this.setLoggedIn(true);
}, () => {
this.setLoggedIn(false);
});
}
public togglePlay(track?: string, force?: boolean): Promise<any> {
return new Promise((resolve, reject) => {
if (track && (track !== this._loadedTrack)) {
// first time play or changing track
this.play(track).then(resolve, reject);
} else if (this.player) {
// toggling
this._playing = typeof force !== 'undefined' ? force : !this._playing;
if (this._playing) {
this.player.resume();
} else {
this.player.pause();
}
resolve(this._playing);
}
});
}
public setVolume(val: number): Promise<any> {
return new Promise((resolve, reject) => {
if (this._audioController) {
this._audioController.setVolume(val);
resolve();
} else {
reject();
}
});
}
private play(track: string): Promise<any> {
return new Promise((resolve, reject) => {
this.checkPlayer().then(() => {
if (!this._playerLoggedIn) {
this._playerLoggedIn = true;
}
this.playUri(track, resolve, reject);
}, () => {
reject('login');
});
});
}
private playUri(track: string, resolve: Function, reject: Function) {
console.log(`playUri`, this.player);
this.player.play(track);
this._loadedTrack = track;
this._playing = true;
resolve(true);
}
private checkPlayer(): Promise<boolean> {
return new Promise((resolve, reject) => {
if (!this._started) {
let activity = app.android.startActivity || app.android.foregroundActivity;
let playerConfig: any = new Config(activity, TNSSpotifyAuth.SESSION, TNSSpotifyConstants.CLIENT_ID);
let builder = new Builder(playerConfig);
this._audioController = new CustomAudioController();
builder.setAudioController(this._audioController);
let observer = new Player.InitializationObserver({
onError: (throwable) => {
let msg = throwable.getMessage();
console.log("MainActivity", "Could not initialize player: " + msg);
reject(msg);
},
onInitialized: (player) => {
console.log(`player initialized`, player);
this._started = true;
// check if user is non-premium
TNSSpotifyAuth.CHECK_PREMIUM().then(() => {
resolve();
}, () => {
reject();
});
}
});
this.player = builder.build(observer);
this.player.addPlayerNotificationCallback(new PlayerNotificationCallback({
onPlaybackEvent: (eventType, playerState) => {
console.log('EVENT TYPE: ', eventType);
console.log('PLAYER STATE: ', playerState);
if (this._loadedTrack && (eventType == PlayerNotificationCallback.EventType.TRACK_CHANGED || eventType == PlayerNotificationCallback.EventType.END_OF_CONTEXT)) {
eventType = eventType == PlayerNotificationCallback.EventType.END_OF_CONTEXT ? 'TRACK_END' : eventType;
if (this.events && playerState.trackUri) {
if (this._trackTimeout) {
clearTimeout(this._trackTimeout);
}
this._trackTimeout = setTimeout(() => {
let trackId = playerState.trackUri.split(':').slice(-1);
console.log(`trackId: ${trackId}`);
http.request({
url: `https://api.spotify.com/v1/tracks/${trackId}`,
method: 'GET',
headers: {
"Content-Type": "application/json",
"Authorization:": `Bearer ${TNSSpotifyAuth.SESSION}` }
}).then((res: any) => {
if (res && res.content) {
let track = JSON.parse(res.content);
this._changedPlaybackState.data.state = {
currentTrack: {
uri: track.uri,
name: track.name,
albumName: track.album.name,
artistName: track.artists[0].name,
durationMs: track.duration_ms,
positionInMs: playerState.positionInMs,
eventType: eventType
}
};
this.events.notify(this._changedPlaybackState);
if (track.album && track.album.images) {
this._albumArtChange.data.url = track.album.images[0].url;
this.events.notify(this._albumArtChange);
}
}
}, (err: any) => {
console.log(`track data error:`, err);
});
}, 500);
}
}
},
onPlaybackError: (errorType, errorDetails) => {
console.log('ERROR TYPE: ', errorType);
console.log('ERROR DETAILS: ', errorDetails);
if (errorDetails) {
for (let key in errorDetails) {
console.log(`key: ${key}`, errorDetails[key]);
}
}
}
}));
} else {
resolve();
}
});
}
private isLoginError(desc: string): boolean {
if (desc.indexOf('invalid credentials') > -1 || desc.indexOf('NULL') > -1) {
return true;
} else {
return false;
}
}
private loginError() {
this.setLoggedIn(false);
Utils.alert('You need to login to renew your session.');
}
private setLoggedIn(value: boolean) {
this._loggedIn = value;
if (!value) {
this._playerLoggedIn = false;
if (this._started) {
this._started = false;
console.log(`TODO: player dispose()`);
this.player.logout();
setTimeout(() => {
// https://developer.spotify.com/android-sdk-docs/player/
Spotify.destroyPlayer(this.player);
}, 1000);
}
}
}
private playerReady(): void {
if (this.events) {
this._playerReady.data.loggedIn = this._loggedIn;
this.events.notify(this._playerReady);
}
}
private setupEvents() {
// auth state
this.auth.events.on('authLoginChange', (eventData: any) => {
console.log(`this.auth.events.on('authLoginChange'`, eventData.data.status);
this.setLoggedIn(eventData.data.status);
});
// // player events
this.events = new Observable();
this._albumArtChange = {
eventName: 'albumArtChange',
data: {
url: ''
}
};
this._playerReady = {
eventName: 'playerReady',
data: {
loggedIn: false
}
};
// delegate events
this._changedPlaybackStatus = {
eventName: 'changedPlaybackStatus',
data: {
playing: false
}
};
this._changedPlaybackState = {
eventName: 'changedPlaybackState',
data: {
state: {}
}
};
this._seekedToOffset = {
eventName: 'seekedToOffset',
data: {
offset: 0
}
};
this._changedVolume = {
eventName: 'changedVolume',
data: {
volume: 0
}
};
this._changedShuffleStatus = {
eventName: 'changedShuffleStatus',
data: {
shuffle: false
}
};
this._changedRepeatStatus = {
eventName: 'changedRepeatStatus',
data: {
repeat: false
}
};
this._changedToTrack = {
eventName: 'changedToTrack',
data: {
metadata: null
}
};
this._failedToPlayTrack = {
eventName: 'failedToPlayTrack',
data: {
url: null
}
};
this._startedPlayingTrack = {
eventName: 'startedPlayingTrack',
data: {
url: null
}
};
this._stoppedPlayingTrack = {
eventName: 'stoppedPlayingTrack',
data: {
url: null
}
};
this._skippedToNextTrack = {
eventName: 'skippedToNextTrack'
};
this._skippedToPreviousTrack = {
eventName: 'skippedToPreviousTrack'
};
this._activePlaybackDevice = {
eventName: 'activePlaybackDevice'
};
this._inactivePlaybackDevice = {
eventName: 'inactivePlaybackDevice'
};
this._poppedQueue = {
eventName: 'poppedQueue'
};
this._temporaryConnectionError = {
eventName: 'temporaryConnectionError'
};
this._streamError = {
eventName: 'streamError',
data: {
error: null
}
};
this._receivedMessage = {
eventName: 'receivedMessage',
data: {
message: null
}
};
this._streamDisconnected = {
eventName: 'streamDisconnected'
};
}
}
Thanks to BRAD MARTIN !!
16 community plugins
nativescript-audio | nativescript-plugin-firebase | ||
nativescript-coachmarks | nativescript-slides | ||
nativescript-ezaudio | nativescript-social-share | ||
nativescript-fancyalert | nativescript-splashscreen | ||
nativescript-gif | nativescript-spotify | ||
nativescript-loading-indicator | nativescript-swiss-army-knife | ||
nativescript-ng2-fonticon | nativescript-telerik-ui-pro | ||
nativescript-permissions | nativescript-themes |
3 iOS only
11 contributed on or authored
?
iOS
Android
iOS
Android
Android
Conditional Plugin Requiring
Architecture
Angular Native
Firebase
Redux via ngrx/store
ngrx/store - 29 Unique Actions w/ 6 Reducers
ngrx/effects - help facilitate side effects
Fast-forward
Aug. 17th
Spotify IPv6 support lands!
Deployment
- First build: 130 Mb
- Primarily 2 difficult issues
to solve:
1. Angular `moduleId: module.id`
2. Font registration for release builds
Webpack with Angular Native
templateUrl full relative path
Webpack with Angular Native
Custom font registration for iOS
Webpack to rescue
... and Nathanael Anderson!
Final build:
~ 29.1 Mb
- Android - WIP
Open Source as of today!
- iOS
ShoutOutPlay
as of today...
Community thank you:
Nathanael Anderson -
Alex Vakrilov -
Brad Martin -
Josh Sommer -
Osei Fortune -
Jen Looper -
TJ VanToll -
Angular thank you:
- Rob Wormald
- Mike Ryan
- Brandon Roberts
- Brian Troncone
ngrx inspiration!
Nathan Walker
@wwwalkerrun
ShoutOutPlay - {N} Dev Days
By Nathan Walker
ShoutOutPlay - {N} Dev Days
An overview of building ShoutOutPlay with Angular Native.
- 2,194