GSoC 2024: Dashboard Page and Feed Revamp in ListenBrainz-iOS

Hello Everyone!, This is Gaurav Bhardwaj aka theflash_(IRC) and gauravbhardwaj7 on GitHub. This year I was given the opportunity to contribute to ListenBrainz-iOS under the mentorship of Akshat Tiwari(akshaaatt on irc).I am currently a final year student at UIET, Panjab University, Chandigarh. It was a very knowledgeable experience filled with a lot of learnings and takeaways.

Proposal

My proposal aimed to add a dashboard section into the app and revamp the existing feed section of the app. This project aims to seamlessly integrate all functionalities of the ListenBrainz website’s Dashboard into the ListenBrainz application. This integration will be achieved through a combination of web view components for certain features and native development for others, depending on the specific requirements of each functionality.

I have used SwiftUI primarily throughout the app, Combine for asynchronous task management and Alamofire library for Networking.

Pre Community Bonding Period

I started contributing in this project in April of 2023, way before my community bonding period. During this period I made the basic feed section, player section, Listens section and YearInMusic(the yearly music stats) into the app. Also, I fixed many bugs and added on-boarding in the app. I became well aware of the codebase and how things work in MetaBrainz even before the proposals were submitted.

Architecture(MVVM)

Architecture

  • Model – To handle API responses, structs are made that match the structure of the JSON response.
  • Repository and RepositoryImpl – In this architecture, the Repository interface defines the methods we need to fetch data from external sources. After this there is a concrete implementation, RepositoryImpl, which handles the actual interactions with APIs. Separating the interface from the implementation gives me flexibility, allowing me to switch data sources easily or use mock data for testing.
  • Views – In the View Layer, SwiftUI views observe the ViewModel using @ObservedObject or @EnvironmentObject. This means the views automatically update when the data in the ViewModel changes, reflecting the current state.
  • ViewModel – The ViewModel in my project acts as the intermediary between the views and the repository. It fetches data from the repository, processes it, and then makes it view-friendly. This might involve formatting dates or organizing the data to fit the structure needed by the views.
  • For instance, in my DashboardViewModel, I fetch playlists and user activity from the repository, format the data, and expose it to the SwiftUI views. I also handle the UI state, such as showing loading indicators when the data is being fetched and managing any errors that might occur. The ViewModel uses @Published properties, so the views automatically react to any changes in the data.

Coding Period (before MidTerm)

The execution of my project is divided into two parts, before and after midterm. I revamped the feed section before my midterm and added Dashboard after midterm.

Before midterm, I mainly worked on –

1. Search Users– Allows users to search all the global users of ListenBrainz.

2. Generic Listens View – Made a Generic Listens View and made a protocol such that all other views can conform to it and all the listens can be represented using one single generic view.

  • TrackInfoView is a generic SwiftUI view that can handle any type conforming to TrackMetadataProvider, which is a struct in model to get parameters from different models.
protocol TrackMetadataProvider {
    var trackName: String? { get }
    var artistName: String? { get }
    var coverArtURL: URL? { get }
    var originURL: String? { get }
    var recordingMbid: String? { get }
    var recordingMsid: String? { get }
    var entityName: String? { get }
}
  • We have used these generalised parameters from feed, taste, playlists and listens model to integrate single generic track info view.
struct TrackInfoView<T: TrackMetadataProvider>: View {
  let item: T
  let onPinTrack: (T) -> Void
  let onRecommendPersonally: (T) -> Void
  let onWriteReview: (T) -> Void

  var body: some View {
    HStack {
      if let coverArtURL = item.coverArtURL {
          AsyncImage(
              url: coverArtURL,
              scale: 0.1,
              transaction: Transaction(animation: nil),
              content: { phase in
                  switch phase {
                  case .success(let image):
                      image
                          .resizable()
                          .scaledToFit()
                          .frame(width: 60, height: 60)
                          .onAppear {
                              imageLoader.loadImage(url: coverArtURL) { loadedImage in
                                  if let uiImage = loadedImage {
                                      ImageCache.shared.insertImage(uiImage, for: coverArtURL)
                                  }
                              }
                          }
      VStack(alignment: .leading) {
        Text(item.trackName ?? "Unknown Track")
        Text(item.artistName ?? "Unknown Artist")
      }

      Spacer()

      Button("Pin this track") {
        onPinTrack(item)
      }
      Button("Personally recommend") {
        onRecommendPersonally(item)
      }
      Button("Write a review") {
        onWriteReview(item)
      }
    }
  }
}
  • Closures for Actions: The view takes closures as parameters (onPinTrack, onRecommendPersonally, and onWriteReview), making it easy to decouple the action logic from the view itself. These closures can be defined outside the view and passed in as needed.

3. Add Dialogs in Listen – Used native dialogs for performing functions such as Open in Spotify, Open in MusicBrainz, Pin, Recommend and Write a review.

Dialog

4. Add Pin, CritiqueBrainz review and Recommend Users – Integrated POST endpoints for pinning, recommending and adding a CritiqueBrainz review to a listen by sending userToken, recordingMsid and recordingMbid as parameters. Used custom made centered modals to depict the views for pinning, recommending and writing a review.

struct CenteredModalView<ModalContent: View>: ViewModifier
    let modalContent: ModalContent
    @Binding var isPresented: Bool

    init(isPresented: Binding<Bool>, @ViewBuilder content: () -> ModalContent) {
        self.modalContent = content()
        self._isPresented = isPresented
    }

    func body(content: Content) -> some View {
        ZStack {
            content
                .blur(radius: isPresented ? 3 : 0)

            if isPresented {
                VStack {
                    modalContent
                        .background(Color(.systemBackground))
                        .cornerRadius(10)
                        .shadow(radius: 10)
                        .overlay(
                            Button(action: {
                                withAnimation {
                                    isPresented = false
                                }
                            }) {
                                Image(systemName: "xmark.circle.fill")
                                    .foregroundColor(.gray)
                                    .padding()
                            },
                            alignment: .topTrailing
                        )
                }
                .frame(maxWidth: .infinity, maxHeight: .infinity)
                .background(Color.black.opacity(0.4))
                .edgesIgnoringSafeArea(.all)
                .transition(.opacity)
            }
        }
    }
}

extension View {
    func centeredModal<ModalContent: View>(isPresented: Binding<Bool>, @ViewBuilder content: @escaping () -> ModalContent) -> some View {
        self.modifier(CenteredModalView(isPresented: isPresented, content: content))
    }
}

5. Pagination

  • Implemented Pagination in feed section to load the events in the form of batches.
  • Pagination tracks the currentPage, itemsPerPage, and canLoadMorePages to control data fetching.
  • Guard conditions prevent multiple fetches while loading or when no more pages are available.
  • Fetched events are appended to the list, and the page increments if new data is available.
  • Pagination resets with resetPagination, clearing data and enabling fresh loading when necessary.

6. UI additions

  • Added timestamps in the feed.
  • Changed the UI to show the review of the tracks added by user.
  • Added delete event in the feed.

Coding Period (After MidTerm)

1. Followers/Following and Listen Count– Used endpoints for followers/following and listen count of the current user.

2. Statistics Section – Made use of various endpoints to get the Listening Activity, Top Artists, Top Albums and Daily Activity of the users.

  • Leveraged Charts Library for making charts in Listening Activity section.
  • Incorporated Grids for making heatmaps to depict daily activity.
  • Made custom listen cards for top tracks, top albums and top artists.

These two sections have been covered in this PR.

3.) Taste – Used endpoints for Loved/Hated on the basis of score in the response and Also depicts the pins of the User.

  func getTaste(userName: String) {
         guard !userName.isEmpty else {
             self.lovedTastes = []
             self.hatedTastes = []
             return
         }

         repository.getTaste(userName: userName)
             .receive(on: DispatchQueue.main)
             .sink(receiveCompletion: { [weak self] completion in
                 switch completion {
                 case .finished:
                     self?.error = nil
                 case .failure(let error):
                   self?.error = error.localizedDescription
                   print("Error fetching da \(error.localizedDescription)")
                 }
             }, receiveValue: { [weak self] tasteResponse in
                 self?.lovedTastes = tasteResponse.feedback.filter { $0.score == 1 }
                 self?.hatedTastes = tasteResponse.feedback.filter { $0.score == -1 }
             })
             .store(in: &subscriptions)
     }
  • Classified the tracks as loved and hated on the basis of their scores (1 for loved, -1 for hated).

4.) Playlists – Used the Playlists endpoint to get the playlists created by the user and fetch tracks for the same.

5.) Created For You – This provides various stats such as Weekly Jams, Weekly Exploration, Last Week’s jams, Top discoveries and Missed recordings for the user.

 func extractPlaylistId(from identifierURL: String) -> String? {
          let regexPattern = "https://listenbrainz.org/playlist/([a-zA-Z0-9\\-]+)"
          let regex = try? NSRegularExpression(pattern: regexPattern, options: [])
          let nsString = identifierURL as NSString
          let results = regex?.firstMatch(in: identifierURL, options: [], range: NSRange(location: 0, length: nsString.length))

          if let range = results?.range(at: 1) {
              return nsString.substring(with: range)
          }

          return nil
      }
  • Used regression function to get the playlist id from the identifier returned by get playlists and get recommendations api call.
  • The identifier is of the following form
"identifier": "https://listenbrainz.org/playlist/05253de7-2c2a-4ce5-9f3a-b667ad80ea74"
  • Get the playlist id from the last part of the identifier and use it in further requests to load the tracks in playlists and created for you section.

The section 4 and 5 has been implemented in this PR.

I have been following this mockup for dashboard keeping in mind the comments by aerozol(design lead).

Mockup for dashboard

6.) User Navigation (Work In Progress) – This feature allows the user to see the dashboard of the other users. I have implemented another view model for handling the username changes and calling them according to whether the user is logged in or other user.

   Group {
            if event.eventType == "notification" {
                TextViewRepresentable(text: event.metadata.message ?? "", linkColor: .blue, foregroundColor: foregroundColor)
                    .frame(maxWidth: .infinity, alignment: .leading)
                    .foregroundColor(foregroundColor)
            } else {
                HStack {
                    NavigationLink(destination: listensViewDestination(for: event)) {
                        Text(replaceUsernameIfNeeded(event.userName))
                            .foregroundColor(Color.LbPurple)
                    }
                    Text(eventDescriptionSuffix(for: event))
                        .foregroundColor(foregroundColor)
                }
            }
        }
  • In the EventDescriptionView, A navigation link has been established, when the user taps on the username, that selectedUserName is parsed to all the requests in all the stats provided in dashboard page.
import SwiftUI

class UserSelection: ObservableObject {
    @Published var selectedUserName: String = ""

    func selectUserName(_ username: String) {
        selectedUserName = username
    }

    func resetUserName() {
        selectedUserName = ""
    }
}
  • When Listens view is reached through navigation, selectedUserName is parsed otherwise it takes the username form AppStorage which is already been saved in app.

What’s left

  • UI/UX refinements and a few bugs related to that.
  • Unit tests for dashboard

Current State

The app is currently availaible on Testflight and we will be releasing the beta version of the app on App Store soon! 😀

Experience

This summer has been one of the most learning experience for me in my entire life , I am immensely grateful to my mentor Akshat Tiwari and all the org members for guiding me throughout the project and help me overcome all the blockers during the coding period.

I am feeling more confident and experienced in Swift after working on this project and I will look forward to contribute in this amazing project in future too!.

I want to thank MetaBrainz for providing me with this amazing opportunity and making me push the boundaries!

Leave a Reply

Your email address will not be published. Required fields are marked *