Consumir API Spotify com Vue.js e Ionic

Consumir API do Spotify com Vue.js e Ionic

Resumo em construção
Voltar

Material complementar:


Conceito:

Criar projeto frontend Ionic com Vue.js para consumir a API REST do Spotify, com Vue Router e TypeScript. Objetivo é usuário informar ID playlist, e mostrará informações da mesma. Projeto baseado na API REST oficial do Spotify.


Passo a passo:

  1. Conta no Spotify (gratuita);
  2. Conta no Postman (gratuita);
  3. Nodejs (npm);
  4. Ionic Vue, router e TypeScript: npm install -g @ionic/cli @vue/cli typescript vue-router@4
  5. Criar projeto: ionic start prjSpotify blank --type=vue
  6. Entrar no projeto: cd prjSpotify
  7. Axios no projeto: npm install axios
  8. Programar arquivos, conforme abaixo
  9. No projeto, instalar dependências: npm install
  10. Executar projeto: ionic serve --host=127.0.0.1
  11. Em Spotify Developers, Create App:
    • name: prjSpotify
    • description: Projeto para consumir API REST do Spotify com Vue.js (vue router e TypeScript) e Ionic
    • Redirect URIs: http://127.0.0.1:8100/callback
    • Which API/SDKs are you planning to use: Web API
    • Aceitar termos, Save, e copiar Client ID e Client secret (OAuth 2)
    • Adicione seu login Spotify aos usuários autorizados desse App

src/components/PlaylistForm.vue:


<template>
    <ion-page>
        <ion-header>
            <ion-toolbar color="primary">
                <ion-title class="ion-text-center ">Spotify Playlist</ion-title>
            </ion-toolbar>
        </ion-header>

        <ion-content class="ion-padding">

            <ion-card v-if="!hasToken" class="ion-text-center">
                <ion-card-header>
                    <ion-card-title>Conectar ao Spotify</ion-card-title>
                    <ion-card-subtitle>Acesse suas playlists públicas</ion-card-subtitle>
                </ion-card-header>
                <ion-button expand="block" color="success" @click="loginWithSpotify">
                    Login com Spotify
                </ion-button>
            </ion-card>

            <ion-card v-if="hasToken">
                <ion-card-header>
                    <ion-card-title>Buscar Playlist</ion-card-title>
                </ion-card-header>
                <ion-card-content>
                    <ion-item>
                        <ion-label position="stacked">URL da Playlist</ion-label>
                        <ion-input v-model="url" placeholder="https://open.spotify.com/playlist/..."
                            clear-input></ion-input>
                    </ion-item>
                    <ion-button expand="block" class="ion-margin-top" :disabled="!url" @click="submit">
                        Ver Playlist
                    </ion-button>
                </ion-card-content>
            </ion-card>

            <div v-if="loading" class="ion-text-center ion-margin-top">
                <ion-spinner name="crescent" />
                <p>Carregando playlist...</p>
            </div>

            <div v-if="playlist">
                <ion-grid>
                    <ion-row class="ion-justify-content-center">
                        <ion-col size="12" size-md="6" size-lg="4">
                            <ion-card class="playlist-cover-card ion-text-center ion-padding">
                                <ion-img :src="playlist.images[0]?.url" alt="Capa da Playlist" />
                                <h1>{{ playlist.name }}</h1>
                                <p>{{ playlist.description || 'Sem descrição disponível' }}</p>
                            </ion-card>
                        </ion-col>
                    </ion-row>

                    <ion-row>
                        <ion-col v-for="item in playlist.tracks.items" :key="item.track.id" size="12" size-sm="6"
                            size-md="4" size-lg="3" class="ion-padding">
                            <ion-card class="track-card ion-text-light">
                                <ion-img :src="item.track.album.images[1]?.url || item.track.album.images[0]?.url"
                                    alt="Capa do Álbum" />
                                <ion-card-header>
                                    <ion-card-title>{{ item.track.name }}</ion-card-title>
                                    <ion-card-subtitle>{{item.track.artists.map(a => a.name).join(', ')
                                        }}</ion-card-subtitle>
                                </ion-card-header>
                                <ion-card-content class="ion-text-center">
                                    <ion-button expand="block" color="success" shape="round" fill="solid"
                                        :href="item.track.external_urls.spotify" target="_blank"
                                        rel="noopener noreferrer">
                                        Ouvir
                                        <ion-icon name="play-outline" slot="end" />
                                    </ion-button>
                                </ion-card-content>
                            </ion-card>
                        </ion-col>
                    </ion-row>
                </ion-grid>
            </div>

        </ion-content>
    </ion-page>
</template>

<script lang="ts" setup>
import { ref } from 'vue';
import axios from 'axios';
import {
    IonPage,
    IonHeader,
    IonToolbar,
    IonTitle,
    IonContent,
    IonInput,
    IonItem,
    IonButton,
    IonCard,
    IonCardHeader,
    IonCardTitle,
    IonCardSubtitle,
    IonCardContent,
    IonGrid,
    IonRow,
    IonCol,
    IonSpinner,
    IonIcon,
    IonImg
} from '@ionic/vue';

import { redirectToSpotifyLogin, getAccessToken } from '../services/auth';
import { playOutline } from 'ionicons/icons';
import { addIcons } from 'ionicons';

addIcons({ 'play-outline': playOutline });

const url = ref('');
const playlist = ref<any | null>(null);
const loading = ref(false);
const hasToken = ref(!!getAccessToken());

function loginWithSpotify() {
    redirectToSpotifyLogin();
}

async function submit() {
    const token = getAccessToken();
    if (!token) {
        alert('Você precisa estar logado no Spotify.');
        return;
    }

    const match = url.value.match(/playlist\/([a-zA-Z0-9]+)/);
    if (!match) {
        alert('URL inválida.');
        return;
    }

    const playlistId = match[1];
    loading.value = true;
    playlist.value = null;

    try {
        const { data } = await axios.get(`https://api.spotify.com/v1/playlists/${playlistId}`, {
            headers: { Authorization: `Bearer ${token}` }
        });
        playlist.value = data;
    } catch {
        alert('Erro ao buscar playlist. Verifique se a playlist é pública ou seu token está válido.');
    } finally {
        loading.value = false;
    }
}
</script>

<style scoped>
ion-card.track-card {
    border-radius: 16px;
    overflow: hidden;
    background-color: #222;
    box-shadow: 0 4px 20px rgba(0, 0, 0, 0.7);
    transition: transform 0.2s ease, box-shadow 0.2s ease;
    margin: 0 12px 20px 12px;
}

ion-card.track-card:hover {
    transform: translateY(-6px);
    box-shadow: 0 8px 28px rgba(0, 0, 0, 0.9);
}

ion-card.track-card ion-img {
    height: 200px;
    object-fit: cover;
}

ion-card.track-card ion-card-title {
    font-size: 1.2rem;
    font-weight: 700;
    color: #fff;
}

ion-card.track-card ion-card-subtitle {
    font-size: 1rem;
    color: #bbb;
}

ion-card.playlist-cover-card {
    border-radius: 20px;
    background-color: #1e1e1e;
    box-shadow: 0 8px 30px rgba(0, 0, 0, 0.8);
    margin: 24px auto 32px auto;
    max-width: 100%;
    color: #eee;
    transition: box-shadow 0.3s ease;
}

ion-card.playlist-cover-card:hover {
    box-shadow: 0 12px 40px rgba(0, 0, 0, 1);
}

ion-card.playlist-cover-card ion-img {
    max-width: 220px;
    height: auto;
    border-radius: 24px;
    object-fit: cover;
    margin: 0 auto 20px auto;
    box-shadow: 0 6px 16px rgba(0, 0, 0, 0.7);
}

ion-card.playlist-cover-card h1 {
    font-size: 2rem;
    font-weight: 800;
    margin-bottom: 12px;
    color: #fff;
}

ion-card.playlist-cover-card p {
    font-size: 1.1rem;
    color: #ccc;
    padding: 0 12px;
    line-height: 1.4;
    user-select: text;
}
</style>

src/router/index.ts:


import { createRouter, createWebHistory } from 'vue-router';
import HomeView from '../views/HomeView.vue';
import CallbackView from '../views/CallbackView.vue';

const routes = [
    {
        path: '/',
        name: 'Home',
        component: HomeView
    },
    {
        path: '/callback',
        name: 'Callback',
        component: CallbackView
    }
];

const router = createRouter({
    history: createWebHistory(),
    routes
});

export default router;

src/services/auth.ts:


const CLIENT_ID = import.meta.env.VITE_SPOTIFY_CLIENT_ID;
const REDIRECT_URI = 'http://127.0.0.1:8100/callback';
const SCOPES = ['playlist-read-private', 'playlist-read-collaborative'];
const AUTH_ENDPOINT = 'https://accounts.spotify.com/authorize';
const TOKEN_ENDPOINT = 'https://accounts.spotify.com/api/token';

function generateCodeVerifier(): string {
    const array = new Uint8Array(64);
    crypto.getRandomValues(array);
    return btoa(String.fromCharCode(...array))
        .replace(/\+/g, '-')
        .replace(/\//g, '_')
        .replace(/=+$/, '');
}

async function generateCodeChallenge(verifier: string): Promise<string> {
    const data = new TextEncoder().encode(verifier);
    const digest = await crypto.subtle.digest('SHA-256', data);
    const base64 = btoa(String.fromCharCode(...new Uint8Array(digest)));
    return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

export async function redirectToSpotifyLogin() {
    const verifier = generateCodeVerifier();
    const challenge = await generateCodeChallenge(verifier);

    const params = new URLSearchParams({
        response_type: 'code',
        client_id: CLIENT_ID,
        scope: SCOPES.join(' '),
        redirect_uri: REDIRECT_URI,
        code_challenge_method: 'S256',
        code_challenge: challenge,
        state: verifier
    });

    window.location.href = `${AUTH_ENDPOINT}?${params.toString()}`;
}

export async function fetchAccessToken(code: string, verifier: string): Promise<string> {
    const body = new URLSearchParams({
        grant_type: 'authorization_code',
        code,
        redirect_uri: REDIRECT_URI,
        client_id: CLIENT_ID,
        code_verifier: verifier
    });

    console.log('Enviando requisição para token com corpo:', Object.fromEntries(body.entries()));

    const response = await fetch(TOKEN_ENDPOINT, {
        method: 'POST',
        headers: {
            'Content-Type': 'application/x-www-form-urlencoded'
        },
        body
    });

    const data = await response.json();
    console.log('Resposta do token:', data);

    if (!response.ok) {
        throw new Error(`Erro ao obter token: ${response.status} - ${JSON.stringify(data)}`);
    }

    if (!data.access_token) {
        throw new Error('access_token ausente na resposta');
    }

    localStorage.setItem('access_token', data.access_token);
    return data.access_token;
}

export function getAccessToken(): string | null {
    return localStorage.getItem('access_token');
}

export function logout() {
    localStorage.removeItem('access_token');
}

src/theme/variables.css:


:root {
    --ion-color-primary: #3880ff;
    --ion-color-primary-rgb: 56, 128, 255;
    --ion-color-primary-contrast: #ffffff;
    --ion-color-primary-contrast-rgb: 255, 255, 255;
    --ion-color-primary-shade: #3171e0;
    --ion-color-primary-tint: #4c8dff;
    --ion-color-secondary: #3dc2ff;
    --ion-color-secondary-rgb: 61, 194, 255;
    --ion-color-secondary-contrast: #ffffff;
    --ion-color-secondary-contrast-rgb: 255, 255, 255;
    --ion-color-secondary-shade: #36abe0;
    --ion-color-secondary-tint: #50c8ff;
    --ion-color-tertiary: #5260ff;
    --ion-color-tertiary-rgb: 82, 96, 255;
    --ion-color-tertiary-contrast: #ffffff;
    --ion-color-tertiary-contrast-rgb: 255, 255, 255;
    --ion-color-tertiary-shade: #4854e0;
    --ion-color-tertiary-tint: #6370ff;
    --ion-color-success: #2dd36f;
    --ion-color-warning: #ffc409;
    --ion-color-danger: #eb445a;
    --ion-color-dark: #222428;
    --ion-color-medium: #92949c;
    --ion-color-light: #f4f5f8;
}

@media (prefers-color-scheme: dark) {
    :root {
        --ion-background-color: #121212;
        --ion-text-color: #ffffff;
        --ion-color-primary: #4c8dff;
        --ion-color-secondary: #50c8ff;
        --ion-color-tertiary: #6370ff;
        --ion-color-success: #2fdf75;
        --ion-color-warning: #ffd534;
        --ion-color-danger: #ff4961;
        --ion-color-dark: #f4f5f8;
        --ion-color-medium: #989aa2;
        --ion-color-light: #222428;
    }
}

src/views/CallbackView.vue:


<template>
    <ion-page>
        <ion-header>
            <ion-toolbar>
                <ion-title>Autenticando...</ion-title>
            </ion-toolbar>
        </ion-header>

        <ion-content class="ion-padding">
            <ion-spinner name="crescent" />
            <p>Autenticando com Spotify...</p>
        </ion-content>
    </ion-page>
</template>

<script lang="ts" setup>
import { onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { fetchAccessToken } from '../services/auth';

const route = useRoute();
const router = useRouter();

onMounted(async () => {
    const code = route.query.code as string;
    const verifier = route.query.state as string; // ← novo

    if (!code || !verifier) {
        alert('Código de autorização ou estado ausente.');
        router.replace('/');
        return;
    }

    try {
        console.log('Código recebido na URL:', code);
        console.log('Verifier recebido via state:', verifier);
        const token = await fetchAccessToken(code, verifier);
        console.log('Token recebido com sucesso:', token);
    } catch (error) {
        console.error('Erro ao obter access_token:', error);
        alert('Erro ao autenticar com o Spotify.');
    } finally {
        router.replace('/');
    }
});
</script>

src/views/HomeView.vue:


<template>
    <PlaylistForm />
</template>

<script lang="ts" setup>
    import PlaylistForm from '../components/PlaylistForm.vue';
</script>

App.vue:


<template>
    <ion-app>
        <ion-content class="ion-padding">
            <router-view />
        </ion-content>
    </ion-app>
</template>

<script lang="ts" setup>
import { IonApp, IonContent } from '@ionic/vue';
</script>

<style scoped>
ion-content {
    --background: var(--ion-background-color);
    color: var(--ion-text-color);
}
</style>

main.ts:


import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import { IonicVue } from '@ionic/vue'
import '@ionic/vue/css/core.css'
import '@ionic/vue/css/normalize.css'
import '@ionic/vue/css/structure.css'
import '@ionic/vue/css/typography.css'
import '@ionic/vue/css/padding.css'
import '@ionic/vue/css/float-elements.css'
import '@ionic/vue/css/text-alignment.css'
import '@ionic/vue/css/text-transformation.css'
import '@ionic/vue/css/flex-utils.css'
import '@ionic/vue/css/display.css'
//import '@ionic/vue/css/palettes/dark.system.css'
import '@ionic/vue/css/palettes/dark.always.css'
import './theme/variables.css'

const app = createApp(App)
    .use(IonicVue)
    .use(router)

router.isReady().then(() => {
    app.mount('#app')
})

.env:


VITE_SPOTIFY_CLIENT_ID=seu_client_id_aqui
VITE_SPOTIFY_CLIENT_SECRET=seu_client_secret_aqui

.gitignore (acrescentar linhas):


node_modules/
dist/
.env

Testes no Postman (GET):

Elaborado por Mateus Schwede
ubsocial.github.io