--- title: "Example: Spotify login to display listening data" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Example: Spotify login to display listening data} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ## Overview This vignette demonstrates the code for an example Shiny application that uses the `shinyOAuth` package to authenticate users via Spotify's OAuth 2.0 service. After logging in, the app fetches and displays data about the user and their listening behaviour in the form of a simple dashboard built with 'bslib'. It shows the user's profile information with their avatar, a live view of what they are currently playing, their top tracks and artists, and a history of recently played songs. For a more detailed explanation of how to use 'shinyOAuth' and its features, see: `vignette("usage", package = "shinyOAuth")`. ## Code ```{r, eval = FALSE} # Example Shiny app using shinyOAuth to connect to Spotify API # # This app demonstrates logging into Spotify with shinyOAuth and fetching # various user statistics via the Spotify Web API. We then build a simple # dashboard to display this information # # Requirements: # - Create a Spotify OAuth 2.0 application at https://developer.spotify.com # - Add a redirect URI that matches redirect_uri below (default: http://127.0.0.1:8100) # - Set environment variables `SPOTIFY_OAUTH_CLIENT_ID` and `SPOTIFY_OAUTH_CLIENT_SECRET` # Load packages & configure OAuth 2.0 client for Spotify ----------------------- library(shiny) library(shinyOAuth) library(bslib) library(ggplot2) library(DT) # Configure provider and client for Spotify provider <- oauth_provider_spotify( # For Spotify, scopes have to be given in the authentication request itself; # `oauth_provider_spotify()` handles this via the `scope` argument scope = paste( c( "user-read-email", "user-read-private", "user-top-read", "user-read-recently-played", "user-read-playback-state", "user-read-currently-playing" ), collapse = " " ) ) client <- oauth_client( provider = provider, client_id = Sys.getenv("SPOTIFY_OAUTH_CLIENT_ID"), client_secret = Sys.getenv("SPOTIFY_OAUTH_CLIENT_SECRET"), redirect_uri = "http://127.0.0.1:8100" ) # Spotify API helpers ---------------------------------------------------------- # Small helpers to call Spotify API with the user's access token # We define a few specialized functions for common endpoints spotify_get <- function(token, path, query = list()) { url <- paste0("https://api.spotify.com", path) req <- client_bearer_req(token, url, query = query) resp <- req_with_retry(req) if (httr2::resp_is_error(resp)) { msg <- sprintf("Spotify API error: HTTP %s", httr2::resp_status(resp)) stop(msg, call. = FALSE) } httr2::resp_body_json(resp, simplifyVector = TRUE) } # Specialized helper for endpoints that may return 204 (e.g., currently-playing) spotify_get_maybe_empty <- function(token, path, query = list()) { url <- paste0("https://api.spotify.com", path) req <- client_bearer_req(token, url, query = query) resp <- req_with_retry(req) status <- httr2::resp_status(resp) if (status == 204L) { return(NULL) } if (httr2::resp_is_error(resp)) { msg <- sprintf("Spotify API error: HTTP %s", status) stop(msg, call. = FALSE) } httr2::resp_body_json(resp, simplifyVector = TRUE) } # Fetch top tracks and artists (short_term: last 4 weeks) get_top_tracks <- function(token, limit = 10, time_range = "short_term") { out <- spotify_get( token, "/v1/me/top/tracks", query = list(limit = limit, time_range = time_range) ) items <- out$items %||% list() if (length(items) == 0) { return(data.frame()) } df <- purrr::map(seq_along(items), function(i) { item <- items[i, ] data.frame( name = item$name %||% NA_character_, artist = paste(item$artists[[1]]$name, collapse = ", "), album = item$album$name %||% NA_character_, popularity = as.numeric(item$popularity) %||% NA_real_, stringsAsFactors = FALSE ) }) |> dplyr::bind_rows() df } # Fetch top artists get_top_artists <- function(token, limit = 10, time_range = "short_term") { out <- spotify_get( token, "/v1/me/top/artists", query = list(limit = limit, time_range = time_range) ) items <- out$items %||% list() if (length(items) == 0) { return(data.frame()) } df <- purrr::map(seq_along(items), function(i) { item <- items[i, ] data.frame( name = item$name %||% NA_character_, genres = paste( as.character(item$genres |> purrr::flatten() %||% character()), collapse = ", " ), popularity = as.numeric(item$popularity) %||% NA_real_, followers = as.numeric(item$followers$total %||% NA_real_), stringsAsFactors = FALSE ) }) |> dplyr::bind_rows() df } # Get recently played tracks get_recently_played <- function(token, limit = 20) { out <- spotify_get( token, "/v1/me/player/recently-played", query = list(limit = limit) ) items <- out$items %||% list() if (length(items) == 0) { return(data.frame()) } df <- purrr::map(seq_along(items), function(i) { item <- items[i, ] data.frame( played_at = as.POSIXct(item$played_at %||% NA_character_, tz = "UTC"), track = item$track$name %||% NA_character_, artist = paste(item$track$artists[[1]]$name, collapse = ", "), album = item$track$album$name %||% NA_character_, stringsAsFactors = FALSE ) }) |> dplyr::bind_rows() df } # Currently playing (may be NULL if nothing is playing) get_currently_playing <- function(token) { out <- spotify_get_maybe_empty(token, "/v1/me/player/currently-playing") if (is.null(out)) { return(NULL) } # Normalize essential fields with guards item <- out$item if (is.null(item)) { return(NULL) } artists <- tryCatch( { if (!is.null(item$artists) && length(item$artists) > 0) { paste(item$artists$name, collapse = ", ") } else { "—" } }, error = function(e) "—" ) art_url <- tryCatch( { item$album$images$url[[1]] }, error = function(e) NULL ) list( is_playing = isTRUE(out$is_playing), progress_ms = as.numeric(out$progress_ms %||% NA_real_), duration_ms = as.numeric(item$duration_ms %||% NA_real_), track = item$name %||% "—", artist = artists, album = item$album$name %||% "—", art = art_url ) } # Helper to safely validate data frames returned from API calls safe_df <- function(x) { if (inherits(x, "try-error")) { return(NULL) } if (is.null(x) || !is.data.frame(x) || nrow(x) == 0) { return(NULL) } x } # Format milliseconds to m:ss format_ms <- function(ms) { if (is.null(ms) || is.na(ms)) { return("—") } s <- round(as.numeric(ms) / 1000) sprintf("%d:%02d", s %/% 60, s %% 60) } # Shiny app -------------------------------------------------------------------- ## Theme & CSS ----------------------------------------------------------------- # Some basic Bootstrap theming spotify_theme <- bs_theme( version = 5, base_font = font_google("Inter"), heading_font = font_google("Space Grotesk"), bg = "#121212", fg = "#F5F6F8", primary = "#1DB954", secondary = "#191414", success = "#1ED760", "navbar-bg" = "#0F0F0F", "card-border-color" = "#1DB95433" ) # Add CSS spotify_theme <- bs_add_rules( spotify_theme, paste( "body { background: radial-gradient(circle at top left, #1DB95411, #121212 55%); }", ".navbar-dark { border-bottom: 1px solid #1DB95422; }", ".card { background-color: #181818; border-radius: 18px; box-shadow: 0 18px 30px -24px rgba(0,0,0,0.7); transition: transform 0.2s, box-shadow 0.2s; }", ".card:hover { box-shadow: 0 20px 35px -20px rgba(29, 185, 84, 0.3); }", ".card-header { background-color: rgba(29, 185, 84, 0.08); border-bottom: 1px solid rgba(29, 185, 84, 0.2); font-weight: 600; }", ".profile-avatar { width: 72px; height: 72px; border-radius: 50%; object-fit: cover; box-shadow: 0 0 0 3px #1DB95455; transition: box-shadow 0.3s; }", ".profile-avatar:hover { box-shadow: 0 0 0 4px #1DB954; }", ".login-hero { min-height: 60vh; }", ".login-card { background: linear-gradient(130deg, #1DB954 0%, #1AA34A 55%, #121212 100%); color: #0C0C0C; border: none; }", ".login-card .btn { background-color: #121212; color: #F5F6F8; border: none; transition: all 0.3s; }", ".login-card .btn:hover { background-color: #0f0f0f; color: #1DB954; transform: scale(1.05); }", ".value-box { background: linear-gradient(135deg, #1a1a1a 0%, #0f0f0f 100%); border: 1px solid #1DB95433; border-radius: 12px; transition: border-color 0.3s; padding: 0.6rem 0.9rem !important; }", ".value-box:hover { border-color: #1DB95466; }", ".value-box .value { font-size: 1.3rem; font-weight: 700; color: #1DB954; line-height: 1.2; }", ".value-box .value-box-title, .value-box h6, .value-box .title { font-size: 0.85rem; letter-spacing: .02em; opacity: .95; }", ".value-box p { margin-bottom: 0; font-size: 0.9rem; }", ".value-box .showcase-icon { color: #1DB954; opacity: 0.7; }", ".table { color: #F5F6F8; margin-bottom: 0; }", ".table thead { color: #1DB954; font-weight: 600; border-bottom: 2px solid #1DB95444; }", ".table tbody tr { transition: background-color 0.2s; }", ".table tbody tr:hover { background-color: rgba(29, 185, 84, 0.15); }", ".table td { vertical-align: middle; padding: 0.75rem; }", ".table td:first-child { color: #1DB954; font-weight: 600; width: 40px; text-align: center; }", ".control-card { background: rgba(16, 16, 16, 0.7); border: 1px solid #1DB95422; }", ".badge { font-size: 0.85rem; padding: 0.4em 0.8em; }", ".play-count-badge { background: linear-gradient(135deg, #1DB954 0%, #1AA34A 100%); color: #000; font-weight: 700; }", ".navbar .navbar-nav { display: none !important; }", sep = "\n" ) ) # Subtle readability and responsive polish overrides spotify_theme <- bs_add_rules( spotify_theme, paste( "/* Ensure cards don't collapse too small on narrow screens */", ".card { min-width: 300px; }", "/* Avoid horizontal scroll within cards */", ".card .card-body { overflow-x: hidden; }", "/* Add gap between cards in layout_columns */", ".bslib-grid { gap: 1rem !important; }", "/* Ensure proper wrapping for cards - prevent cards from becoming too narrow */", ".bslib-grid > div { min-width: 300px; flex: 1 1 300px; }", "/* Prevent value box containers from collapsing */", ".card-body .bslib-grid { display: flex; flex-wrap: wrap; }", "/* Softer login gradient and better contrast */", ".login-card { background: linear-gradient(145deg, rgba(29,185,84,0.18) 0%, rgba(29,185,84,0.08) 38%, #1a1a1a 100%); color: #F5F6F8; border: 1px solid #1DB95422; overflow: hidden; }", ".login-card .btn { background-color: #121212; color: #F5F6F8; border: 1px solid #1DB95444; transition: background-color 0.25s, color 0.25s, box-shadow 0.25s; }", ".login-card .btn:hover { background-color: #0f0f0f; color: #1DB954; box-shadow: 0 8px 22px rgba(29,185,84,0.22); }", ".login-card .btn:focus, .login-card .btn:focus-visible { outline: none; box-shadow: 0 0 0 0.2rem rgba(29,185,84,0.35); }", "/* Improve muted text contrast inside cards/value boxes */", ".card .text-muted, .value-box .text-muted { color: #CFD3D8 !important; }", "/* DataTables dark theme tweaks */", ".dataTables_wrapper .dataTables_length select, .dataTables_wrapper .dataTables_filter input { background-color: #0f0f0f; color: #F5F6F8; border: 1px solid #1DB95433; }", ".dataTables_wrapper .dataTables_paginate .paginate_button { color: #F5F6F8 !important; border: 1px solid transparent; }", ".dataTables_wrapper .dataTables_paginate .paginate_button.current, .dataTables_wrapper .dataTables_paginate .paginate_button:hover { color: #1DB954 !important; background: #0f0f0f; border-color: #1DB95433; }", ".dataTables_wrapper .dataTables_info { color: #E4E7EB; }", "/* Slightly more visible table header border for clarity */", ".table thead { border-bottom: 2px solid #1DB95455; }", "/* Custom Spotify outline button (for Sign out) */", ".btn-spotify-outline { color: #1DB954; border: 1px solid #1DB95499; background: transparent; }", ".btn-spotify-outline:hover { color: #0b0b0b; background: #1DB954; border-color: #1DB954; }", "/* Plan badge for better readability */", ".badge-plan { background: transparent; border: 1px solid #1DB95466; color: #F5F6F8; }", "/* Sidebar toggle visibility */", ".layout-sidebar .collapse-toggle, .layout-sidebar .sidebar-toggle, .bslib-sidebar-layout .collapse-toggle { color: #F5F6F8; border: 1px solid #1DB95455; background: #0f0f0f; }", ".layout-sidebar .collapse-toggle:hover, .layout-sidebar .sidebar-toggle:hover, .bslib-sidebar-layout .collapse-toggle:hover { border-color: #1DB954aa; color: #1DB954; }", "/* Value box compact sizing and min width with proper wrapping */", ".value-box { min-width: 220px; margin-bottom: 0.75rem; flex: 1 1 220px; }", ".value-box .showcase-top, .value-box .showcase-bottom, .value-box .showcase-area { gap: .5rem; }", ".value-box .showcase-icon { font-size: 0.95rem; }", "/* Prevent value box text overflow */", ".value-box .value { word-break: break-word; font-size: 1.1rem !important; }", ".value-box p { word-break: break-word; overflow-wrap: break-word; font-size: 0.85rem; }", ".value-box .title, .value-box h6 { font-size: 0.8rem; }", "/* Now playing artwork sizing */", ".now-playing-art { width: 80px; height: 80px; object-fit: cover; border-radius: 8px; box-shadow: 0 8px 18px rgba(0,0,0,.35); }", sep = "\n" ) ) ## UI -------------------------------------------------------------------------- ui <- bslib::page_fluid( title = tags$span( class = "d-flex align-items-center gap-2", icon("headphones"), span(class = "fw-semibold", "Spotify Listening Studio") ), theme = spotify_theme, use_shinyOAuth(), div( class = "pt-4 pb-5", uiOutput("oauth_error"), conditionalPanel( condition = "output.isAuthenticated", layout_sidebar( sidebar = sidebar( card( class = "control-card", card_header(div( class = "d-flex align-items-center gap-2", icon("sliders-h"), span("Personalize view") )), card_body( selectInput( "time_range", "Listening window", choices = c( "Last 4 weeks" = "short_term", "Last 6 months" = "medium_term", "All-time favorites" = "long_term" ), selected = "short_term" ), sliderInput( "top_limit", "Top items", min = 5, max = 20, value = 10, step = 1 ) ), card_footer(tags$small( class = "text-muted", "Adjust filters to explore different eras of your listening." )) ), width = 320, open = TRUE ), fillable = TRUE, layout_column_wrap( width = "350px", heights_equal = "row", card( card_header(div( class = "d-flex align-items-center gap-2", icon("user"), span("Profile") )), card_body(uiOutput("profile")) ), card( card_header(div( class = "d-flex align-items-center gap-2", icon("play-circle"), span("Listening sessions") )), card_body(uiOutput("summary_boxes")) ), card( card_header(div( class = "d-flex align-items-center gap-2", icon("broadcast-tower"), span("Now playing") )), card_body(uiOutput("now_playing")) ) ), layout_column_wrap( width = "400px", fill = TRUE, card( card_header(div( class = "d-flex align-items-center gap-2", icon("music"), span("Top tracks") )), card_body(DTOutput("top_tracks")) ), card( card_header(div( class = "d-flex align-items-center gap-2", icon("users"), span("Top artists") )), card_body(DTOutput("top_artists")) ) ), layout_column_wrap( width = NULL, fill = TRUE, style = css(grid_template_columns = "3fr 2fr"), card( card_header(div( class = "d-flex align-items-center gap-2", icon("history"), span("Recent plays") )), card_body(DTOutput("recent")) ), card( card_header(div( class = "d-flex align-items-center gap-2", icon("chart-bar"), span("Artists on repeat") )), card_body(plotOutput("recent_artist_plot", height = "400px")) ) ) ) ), conditionalPanel( condition = "!output.isAuthenticated", div( class = "login-hero d-flex justify-content-center align-items-center", card( class = "login-card text-center p-5", card_body( icon("headphones", class = "display-4 mb-3"), h2("Spotify Listening Studio"), p( class = "lead", "Sign in to reveal your personal soundtrack: relive your top tracks, spotlight your favorite artists, and surface the songs you can't stop replaying." ), actionButton( "login", "Sign in with Spotify", class = "btn btn-lg px-4 py-3 mt-2" ), div( class = "mt-3 small", tags$strong("Scopes:"), " user-top-read • user-read-recently-played • user-read-email • user-read-private" ) ) ) ) ) ) ) ## Server ---------------------------------------------------------------------- server <- function(input, output, session) { # Handle Spotify login ------------------------------------------------------- auth <- oauth_module_server("auth", client, auto_redirect = FALSE) # Expose auth state to JS for our conditionalPanel output$isAuthenticated <- shiny::reactive({ isTRUE(auth$authenticated) }) shiny::outputOptions(output, "isAuthenticated", suspendWhenHidden = FALSE) observeEvent(input$login, { auth$request_login() }) observeEvent(input$logout, { req(isTRUE(auth$authenticated)) auth$logout() }) output$oauth_error <- renderUI({ if (!is.null(auth$error)) { msg <- auth$error if (!is.null(auth$error_description)) { msg <- paste0(msg, ": ", auth$error_description) } div(class = "alert alert-danger", role = "alert", msg) } }) # Show user profile ---------------------------------------------------------- output$profile <- renderUI({ req(auth$token) user_info <- auth$token@userinfo if (length(user_info) == 0) { return(div(class = "text-muted", "No user info")) } avatar <- NULL if ( !is.null(user_info$images) && is.data.frame(user_info$images) && nrow(user_info$images) > 0 ) { img_url <- user_info$images$url[[1]] if (!is.null(img_url) && nzchar(img_url)) { avatar <- tags$img( src = img_url, class = "profile-avatar", alt = "User avatar" ) } } display_name <- user_info$display_name %||% user_info$id %||% "" followers_badge <- NULL if ( !is.null(user_info$followers) && is.list(user_info$followers) && !is.null(user_info$followers$total) ) { followers_badge <- span( class = "badge bg-success-subtle text-success-emphasis", "Followers:", tags$span( class = "ms-1", format(user_info$followers$total, big.mark = ",") ) ) } plan_badge <- NULL if (!is.null(user_info$product)) { plan_badge <- span( class = "badge badge-plan", paste("Plan:", user_info$product) ) } country_badge <- NULL if (!is.null(user_info$country)) { country_badge <- span( class = "badge bg-dark border border-success", paste("Country:", user_info$country) ) } spotify_link <- NULL if ( !is.null(user_info$external_urls) && is.list(user_info$external_urls) && !is.null(user_info$external_urls$spotify) ) { spotify_link <- a( icon("external-link-alt", class = "ms-2"), href = user_info$external_urls$spotify, class = "text-decoration-none text-success", target = "_blank", title = "Open in Spotify" ) } tagList( div( class = "d-flex align-items-center gap-3 flex-wrap", avatar, div( h4(class = "mb-1", display_name, spotify_link), if (!is.null(user_info$email)) { span(class = "text-muted", user_info$email) } ), div( class = "ms-auto", actionButton( "logout", "Sign out", class = "btn btn-spotify-outline btn-sm" ) ) ), hr(class = "border-success-subtle"), div( class = "d-flex flex-wrap gap-2", followers_badge, plan_badge, country_badge ) ) }) # Reactives containing Spotify data ------------------------------------------ # Data fetch reactives top_tracks <- reactive({ req(auth$token, input$time_range, input$top_limit) try( get_top_tracks( auth$token, limit = input$top_limit, time_range = input$time_range ), silent = FALSE ) }) top_artists <- reactive({ req(auth$token, input$time_range, input$top_limit) try( get_top_artists( auth$token, limit = input$top_limit, time_range = input$time_range ), silent = FALSE ) }) recent <- reactive({ req(auth$token) try(get_recently_played(auth$token, limit = 50), silent = FALSE) }) summary_data <- reactive({ tracks_df <- safe_df(top_tracks()) artists_df <- safe_df(top_artists()) recent_df <- safe_df(recent()) list( top_track = if (!is.null(tracks_df)) { list( name = tracks_df$name[1] %||% "—", artist = tracks_df$artist[1] %||% "—" ) } else { NULL }, top_artist = if (!is.null(artists_df)) { list( name = artists_df$name[1] %||% "—", genres = if ( !is.null(artists_df$genres[1]) && nzchar(artists_df$genres[1]) ) { artists_df$genres[1] } else { "—" } ) } else { NULL }, last_play = if (!is.null(recent_df)) { list( track = recent_df$track[1] %||% "—", artist = recent_df$artist[1] %||% "—", played_at = recent_df$played_at[1] ) } else { NULL }, unique_recent = if (!is.null(recent_df)) { dplyr::n_distinct(recent_df$artist) } else { NA_integer_ } ) }) # Summary cards -------------------------------------------------------------- # These show a few different summary stats about the user's listening output$summary_boxes <- renderUI({ data <- summary_data() top_track <- data$top_track top_artist <- data$top_artist last_play <- data$last_play top_track_name <- if (!is.null(top_track)) top_track$name else "—" top_track_artist <- if (!is.null(top_track)) { top_track$artist } else { "No data for this window" } top_artist_name <- if (!is.null(top_artist)) top_artist$name else "—" top_artist_genres <- if (!is.null(top_artist)) { top_artist$genres } else { "No genres available" } last_track_name <- if (!is.null(last_play)) last_play$track else "—" last_track_details <- if (!is.null(last_play)) { parts <- c(last_play$artist %||% "—") if (!is.null(last_play$played_at) && !is.na(last_play$played_at)) { parts <- c(parts, format(last_play$played_at, "%b %d • %H:%M", tz = "")) } paste(parts, collapse = " | ") } else { "No recent playback" } unique_recent <- data$unique_recent unique_recent_value <- if (!is.na(unique_recent)) unique_recent else "—" layout_column_wrap( width = "220px", value_box( title = "Top Track", value = top_track_name, showcase = icon("music"), p(class = "text-muted", top_track_artist) ), value_box( title = "Top Artist", value = top_artist_name, showcase = icon("star"), p(class = "text-muted", top_artist_genres) ), value_box( title = "Recent Session", value = last_track_name, showcase = icon("clock"), p(class = "text-muted", last_track_details) ), value_box( title = "Unique Artists (recent)", value = unique_recent_value, showcase = icon("users"), p(class = "text-muted", "Across your latest 50 plays") ) ) }) # Top tracks ----------------------------------------------------------------- # Shows the user's top tracks in a data table output$top_tracks <- renderDT({ df <- top_tracks() shiny::validate( need(!inherits(df, "try-error"), "Failed to load top tracks"), need(!is.null(df) && nrow(df) > 0, "No tracks returned for this window") ) # Calculate play counts from recent plays recent_df <- safe_df(recent()) if (!is.null(recent_df)) { recent_df$key <- paste0(recent_df$track, " — ", recent_df$artist) df$key <- paste0(df$name, " — ", df$artist) play_counts <- table(recent_df$key) df$plays <- vapply( df$key, function(k) { count <- suppressWarnings(play_counts[k]) if (is.na(count)) 0L else as.integer(count) }, integer(1) ) } else { df$plays <- 0L } # Drop rows that are entirely missing name & artist keep <- (!is.na(df$name) & nzchar(df$name)) | (!is.na(df$artist) & nzchar(df$artist)) df <- df[keep, , drop = FALSE] df <- df[, c("name", "artist", "album", "plays", "popularity")] df$plays <- ifelse(df$plays > 0, sprintf("🔁 %d", df$plays), "—") df$popularity <- ifelse( is.na(df$popularity), "—", sprintf("⭐ %d", round(df$popularity)) ) # Add rank numbers df <- cbind(`#` = seq_len(nrow(df)), df) df <- stats::setNames( df, c("#", "Track", "Artist", "Album", "Recent Plays", "Popularity") ) datatable( df, rownames = FALSE, escape = FALSE, options = list( pageLength = 10, lengthChange = FALSE, order = list(list(0, 'asc')), columnDefs = list( list(orderable = FALSE, targets = 0) ) ) ) }) # Top artists ---------------------------------------------------------------- # Shows the user's top artists in a data table output$top_artists <- renderDT({ df <- top_artists() shiny::validate( need(!inherits(df, "try-error"), "Failed to load top artists"), need(!is.null(df) && nrow(df) > 0, "No artists returned for this window") ) df <- df[, c("name", "genres", "popularity", "followers")] df$genres[df$genres == ""] <- "—" df$genres <- vapply( df$genres, function(g) { if (nchar(g) > 50) paste0(substr(g, 1, 47), "...") else g }, character(1) ) df$popularity <- ifelse( is.na(df$popularity), "—", sprintf("⭐ %d", round(df$popularity)) ) df$followers <- ifelse( is.na(df$followers), "—", paste0("👥 ", format(round(df$followers), big.mark = ",")) ) # Add rank numbers df <- cbind(`#` = seq_len(nrow(df)), df) df <- stats::setNames( df, c("#", "Artist", "Genres", "Popularity", "Followers") ) datatable( df, rownames = FALSE, escape = FALSE, options = list( pageLength = 10, lengthChange = FALSE, order = list(list(0, 'asc')), columnDefs = list( list(orderable = FALSE, targets = 0) ) ) ) }) # Recent plays --------------------------------------------------------------- # Shows the user's recent plays in a data table output$recent <- renderDT({ df <- recent() shiny::validate( need(!inherits(df, "try-error"), "Failed to load recent plays"), need(!is.null(df) && nrow(df) > 0, "No recent plays available") ) df$played <- format(df$played_at, "%b %d • %H:%M", tz = "") df <- df[, c("played", "track", "artist", "album")] # Add rank numbers df <- cbind(`#` = seq_len(nrow(df)), df) df <- stats::setNames(df, c("#", "Played", "Track", "Artist", "Album")) datatable( df, rownames = FALSE, options = list( pageLength = 10, lengthChange = FALSE, order = list(list(0, 'desc')), columnDefs = list( list(orderable = FALSE, targets = 0) ) ) ) }) # Recent artists plot -------------------------------------------------------- # Bar plot of most frequently played artists in recent plays output$recent_artist_plot <- renderPlot({ df_recent <- recent() shiny::validate( need(!inherits(df_recent, "try-error"), "Failed to load recent plays"), need( !is.null(df_recent) && nrow(df_recent) > 0, "No recent plays available" ) ) # Primary: counts from recent plays counts <- sort(table(df_recent$artist), decreasing = TRUE) counts_df <- data.frame( artist = names(counts), plays = as.numeric(counts), stringsAsFactors = FALSE ) # If the recent signal is weak (<= 3 artists or max <= 1), fall back to time-range top artists by popularity use_fallback <- nrow(counts_df) <= 3 || max(counts_df$plays, na.rm = TRUE) <= 1 if (isTRUE(use_fallback)) { df_top <- safe_df(top_artists()) if (!is.null(df_top) && nrow(df_top) > 0) { counts_df <- df_top[, c("name", "popularity")] names(counts_df) <- c("artist", "plays") } } # Take top 10 and order for plotting counts_df <- utils::head( counts_df[order(counts_df$plays, decreasing = TRUE), ], 10L ) counts_df$artist <- factor(counts_df$artist, levels = rev(counts_df$artist)) x_lab <- if (isTRUE(use_fallback)) "Popularity" else "Plays (last 50)" ggplot(counts_df, aes_string(x = "plays", y = "artist")) + geom_col(fill = "#1DB954", width = 0.65) + geom_text(aes(label = plays), hjust = -0.2, color = "#F5F6F8", size = 4) + scale_x_continuous(expand = expansion(mult = c(0, 0.08))) + labs(x = x_lab, y = NULL) + theme_minimal(base_family = "Inter", base_size = 13) + theme( plot.background = element_rect(fill = "#181818", colour = NA), panel.background = element_rect(fill = "#181818", colour = NA), panel.grid.major.y = element_blank(), panel.grid.major.x = element_line(colour = "#FFFFFF22"), text = element_text(colour = "#F5F6F8"), axis.text.y = element_text(colour = "#F5F6F8", size = 12), axis.text.x = element_text(colour = "#F5F6F8", size = 11), plot.margin = margin(10, 20, 10, 20) ) }) # Now playing ---------------------------------------------------------------- # Shows the user's currently playing track with a progress bar output$now_playing <- renderUI({ req(auth$token) # refresh every 5 seconds invalidateLater(5000, session) playing <- try(get_currently_playing(auth$token), silent = FALSE) if (inherits(playing, "try-error") || is.null(playing)) { return(div(class = "text-muted", "Nothing playing right now")) } pct <- NA_real_ if ( !is.na(playing$progress_ms) && !is.na(playing$duration_ms) && playing$duration_ms > 0 ) { pct <- max( 0, min(100, round(playing$progress_ms / playing$duration_ms * 100)) ) } progress_bar <- NULL if (!is.na(pct)) { progress_bar <- div( class = "progress mt-2", div( class = "progress-bar bg-success", role = "progressbar", style = paste0("width: ", pct, "%"), `aria-valuenow` = pct, `aria-valuemin` = 0, `aria-valuemax` = 100 ) ) } time_label <- span( class = "small text-muted", paste(format_ms(playing$progress_ms), "/", format_ms(playing$duration_ms)) ) tagList( div( class = "d-flex gap-3 align-items-center", if (!is.null(playing$art)) { tags$img( src = playing$art, class = "now-playing-art", alt = "Album art" ) }, div( div(class = "fw-semibold", playing$track), div(class = "text-muted", paste(playing$artist, "•", playing$album)) ) ), progress_bar, div(class = "d-flex justify-content-end", time_label) ) }) } # Run app ---------------------------------------------------------------------- shiny::runApp(shinyApp(ui, server), port = 8100) ```