Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions Sources/Kaset/Utilities/MusicNavigationCoordinator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import SwiftUI

// MARK: - MusicNavigationCoordinator

/// Owns music sidebar navigation paths and the active route used by the shared PlayerBar.
@Observable @MainActor
final class MusicNavigationCoordinator {
var homeNavigationPath = NavigationPath()
var exploreNavigationPath = NavigationPath()
var searchNavigationPath = NavigationPath()
var chartsNavigationPath = NavigationPath()
var moodsAndGenresNavigationPath = NavigationPath()
var newReleasesNavigationPath = NavigationPath()
var podcastsNavigationPath = NavigationPath()
var likedMusicNavigationPath = NavigationPath()
var libraryNavigationPath = NavigationPath()
var historyNavigationPath = NavigationPath()
var pinnedNavigationPaths: [String: NavigationPath] = [:]

var activeTab: NavigationItem?
var activePinnedContentID: String?
private(set) var activeRouteHasNavigationStack = false

var routeArtistID: String?
var routeAlbumID: String?

func navigationPathBinding(for item: NavigationItem) -> Binding<NavigationPath> {
switch item {
case .home:
Binding(get: { self.homeNavigationPath }, set: { self.homeNavigationPath = $0 })
case .explore:
Binding(get: { self.exploreNavigationPath }, set: { self.exploreNavigationPath = $0 })
case .search:
Binding(get: { self.searchNavigationPath }, set: { self.searchNavigationPath = $0 })
case .charts:
Binding(get: { self.chartsNavigationPath }, set: { self.chartsNavigationPath = $0 })
case .moodsAndGenres:
Binding(get: { self.moodsAndGenresNavigationPath }, set: { self.moodsAndGenresNavigationPath = $0 })
case .newReleases:
Binding(get: { self.newReleasesNavigationPath }, set: { self.newReleasesNavigationPath = $0 })
case .podcasts:
Binding(get: { self.podcastsNavigationPath }, set: { self.podcastsNavigationPath = $0 })
case .likedMusic:
Binding(get: { self.likedMusicNavigationPath }, set: { self.likedMusicNavigationPath = $0 })
case .library:
Binding(get: { self.libraryNavigationPath }, set: { self.libraryNavigationPath = $0 })
case .history:
Binding(get: { self.historyNavigationPath }, set: { self.historyNavigationPath = $0 })
}
}

func pinnedPathBinding(for contentID: String) -> Binding<NavigationPath> {
Binding(
get: { self.pinnedNavigationPaths[contentID, default: NavigationPath()] },
set: { self.pinnedNavigationPaths[contentID] = $0 }
)
}

func updateActiveRoute(tab: NavigationItem?, pinnedContentID: String?, hasNavigationStack: Bool = true) {
self.activeTab = tab
self.activePinnedContentID = pinnedContentID
self.activeRouteHasNavigationStack = hasNavigationStack
}

func activeNavigationPathBinding() -> Binding<NavigationPath>? {
guard self.activeRouteHasNavigationStack else { return nil }
if let contentID = self.activePinnedContentID {
return self.pinnedPathBinding(for: contentID)
}
if let tab = self.activeTab {
return self.navigationPathBinding(for: tab)
}
return nil
}

var playerBarNavigationAction: PlayerBarNavigationAction {
guard self.activeRouteHasNavigationStack,
let binding = self.activeNavigationPathBinding() else { return .disabled }
return PlayerBarNavigationAction(
openArtist: { artist in binding.wrappedValue.append(artist) },
openAlbum: { album in binding.wrappedValue.append(album) }
)
}

func resetAllNavigationPaths() {
self.homeNavigationPath = NavigationPath()
self.exploreNavigationPath = NavigationPath()
self.searchNavigationPath = NavigationPath()
self.chartsNavigationPath = NavigationPath()
self.moodsAndGenresNavigationPath = NavigationPath()
self.newReleasesNavigationPath = NavigationPath()
self.podcastsNavigationPath = NavigationPath()
self.likedMusicNavigationPath = NavigationPath()
self.libraryNavigationPath = NavigationPath()
self.historyNavigationPath = NavigationPath()
self.pinnedNavigationPaths = [:]
self.routeArtistID = nil
self.routeAlbumID = nil
}

func setRouteAlbumID(_ albumID: String?) {
self.routeAlbumID = albumID
}

func showAlbumRoute(for playlist: Playlist) {
guard playlist.isAlbum else { return }
self.routeAlbumID = playlist.id
}

func hideAlbumRoute(for playlist: Playlist) {
guard playlist.isAlbum, self.routeAlbumID == playlist.id else { return }
self.routeAlbumID = nil
}

func setRouteArtistID(_ artistID: String?) {
self.routeArtistID = artistID
}
}
17 changes: 8 additions & 9 deletions Sources/Kaset/Views/ArtistDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,18 @@ import SwiftUI
/// Detail view for an artist showing their songs and albums.
struct ArtistDetailView: View { // swiftlint:disable:this type_body_length
let artist: Artist
var playerBarNavigationAction: PlayerBarNavigationAction = .disabled
@State var viewModel: ArtistDetailViewModel
@Environment(PlayerService.self) private var playerService
@Environment(AuthService.self) private var authService
@Environment(FavoritesManager.self) private var favoritesManager
@Environment(SongLikeStatusManager.self) private var likeStatusManager
@Environment(MusicNavigationCoordinator.self) private var musicNavigation

init(
artist: Artist,
viewModel: ArtistDetailViewModel,
playerBarNavigationAction: PlayerBarNavigationAction = .disabled
viewModel: ArtistDetailViewModel
) {
self.artist = artist
self.playerBarNavigationAction = playerBarNavigationAction
_viewModel = State(initialValue: viewModel)
}

Expand All @@ -44,11 +42,12 @@ struct ArtistDetailView: View { // swiftlint:disable:this type_body_length
.accentBackground(from: self.viewModel.artistDetail?.thumbnailURL?.highQualityThumbnailURL)
.navigationTitle(self.artist.name)
.toolbarBackgroundVisibility(.hidden, for: .automatic)
.safeAreaInset(edge: .bottom, spacing: 0) {
if case .error = self.viewModel.loadingState {} else {
PlayerBar()
.environment(\.playerBarNavigationAction, self.playerBarNavigationAction)
.environment(\.playerBarCurrentArtistID, self.artist.id)
.onAppear {
self.musicNavigation.setRouteArtistID(self.artist.id)
}
.onDisappear {
if self.musicNavigation.routeArtistID == self.artist.id {
self.musicNavigation.setRouteArtistID(nil)
}
}
.task {
Expand Down
5 changes: 0 additions & 5 deletions Sources/Kaset/Views/ArtistDiscographyView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,6 @@ struct ArtistDiscographyView: View {
.navigationTitle(self.viewModel.destination.sectionTitle)
.toolbarBackgroundVisibility(.hidden, for: .automatic)
.topFade()
.safeAreaInset(edge: .bottom, spacing: 0) {
if case .error = self.viewModel.loadingState {} else {
PlayerBar()
}
}
.task {
if self.viewModel.loadingState == .idle {
await self.viewModel.load()
Expand Down
5 changes: 0 additions & 5 deletions Sources/Kaset/Views/ArtistEpisodesListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,6 @@ struct ArtistEpisodesListView: View {
.navigationTitle(self.viewModel.destination.sectionTitle)
.toolbarBackgroundVisibility(.hidden, for: .automatic)
.topFade()
.safeAreaInset(edge: .bottom, spacing: 0) {
if case .error = self.viewModel.loadingState {} else {
PlayerBar()
}
}
.task {
if self.viewModel.loadingState == .idle {
await self.viewModel.load()
Expand Down
30 changes: 7 additions & 23 deletions Sources/Kaset/Views/ChartsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@ import SwiftUI
/// Charts view displaying top songs, albums, and trending charts.
struct ChartsView: View {
@State var viewModel: ChartsViewModel
@Environment(MusicNavigationCoordinator.self) private var musicNavigation
@Environment(PlayerService.self) private var playerService
@State private var navigationPath = NavigationPath()
@State private var networkMonitor = NetworkMonitor.shared

var body: some View {
NavigationStack(path: self.$navigationPath) {
NavigationStack(path: self.musicNavigation.navigationPathBinding(for: .charts)) {
Group {
if !self.networkMonitor.isConnected {
ErrorView(
Expand All @@ -32,16 +32,7 @@ struct ChartsView: View {
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.localizedNavigationTitle("Charts")
.navigationDestinations(
client: self.viewModel.client,
playerBarNavigationAction: self.playerBarNavigationAction
)
.playerBarMusicNavigation(path: self.$navigationPath)
}
.playerBarMusicNavigation(path: self.$navigationPath)
.safeAreaInset(edge: .bottom, spacing: 0) {
PlayerBar()
.playerBarMusicNavigation(path: self.$navigationPath)
.navigationDestinations(client: self.viewModel.client)
}
.onAppear {
if self.viewModel.loadingState == .idle {
Expand All @@ -53,14 +44,7 @@ struct ChartsView: View {
.refreshable {
await self.viewModel.refresh()
}
.popsNavigationStackOnSidebarReselect(path: self.$navigationPath, for: .charts)
}

private var playerBarNavigationAction: PlayerBarNavigationAction {
PlayerBarNavigationAction(
openArtist: { self.navigationPath.append($0) },
openAlbum: { self.navigationPath.append($0) }
)
.popsNavigationStackOnSidebarReselect(path: self.musicNavigation.navigationPathBinding(for: .charts), for: .charts)
}

// MARK: - Views
Expand Down Expand Up @@ -137,7 +121,7 @@ struct ChartsView: View {
await self.playerService.playWithRadio(song: song)
}
case let .playlist(playlist):
self.navigationPath.append(playlist)
self.musicNavigation.chartsNavigationPath.append(playlist)
case let .album(album):
let playlist = Playlist(
id: album.id,
Expand All @@ -147,9 +131,9 @@ struct ChartsView: View {
trackCount: album.trackCount,
author: Artist.inline(name: album.artistsDisplay, namespace: "album-artist")
)
self.navigationPath.append(playlist)
self.musicNavigation.chartsNavigationPath.append(playlist)
case let .artist(artist):
self.navigationPath.append(artist)
self.musicNavigation.chartsNavigationPath.append(artist)
}
}
}
Expand Down
30 changes: 7 additions & 23 deletions Sources/Kaset/Views/ExploreView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@ import SwiftUI
/// Explore view displaying new releases, charts, and moods & genres.
struct ExploreView: View {
@State var viewModel: ExploreViewModel
@Environment(MusicNavigationCoordinator.self) private var musicNavigation
@Environment(PlayerService.self) private var playerService
@State private var navigationPath = NavigationPath()
@State private var networkMonitor = NetworkMonitor.shared

var body: some View {
NavigationStack(path: self.$navigationPath) {
NavigationStack(path: self.musicNavigation.navigationPathBinding(for: .explore)) {
Group {
if !self.networkMonitor.isConnected {
ErrorView(
Expand All @@ -32,16 +32,7 @@ struct ExploreView: View {
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.localizedNavigationTitle("Explore")
.navigationDestinations(
client: self.viewModel.client,
playerBarNavigationAction: self.playerBarNavigationAction
)
.playerBarMusicNavigation(path: self.$navigationPath)
}
.playerBarMusicNavigation(path: self.$navigationPath)
.safeAreaInset(edge: .bottom, spacing: 0) {
PlayerBar()
.playerBarMusicNavigation(path: self.$navigationPath)
.navigationDestinations(client: self.viewModel.client)
}
.onAppear {
if self.viewModel.loadingState == .idle {
Expand All @@ -53,14 +44,7 @@ struct ExploreView: View {
.refreshable {
await self.viewModel.refresh()
}
.popsNavigationStackOnSidebarReselect(path: self.$navigationPath, for: .explore)
}

private var playerBarNavigationAction: PlayerBarNavigationAction {
PlayerBarNavigationAction(
openArtist: { self.navigationPath.append($0) },
openAlbum: { self.navigationPath.append($0) }
)
.popsNavigationStackOnSidebarReselect(path: self.musicNavigation.navigationPathBinding(for: .explore), for: .explore)
}

// MARK: - Views
Expand Down Expand Up @@ -139,7 +123,7 @@ struct ExploreView: View {
await self.playerService.playWithRadio(song: song)
}
case let .playlist(playlist):
self.navigationPath.append(playlist)
self.musicNavigation.exploreNavigationPath.append(playlist)
case let .album(album):
let playlist = Playlist(
id: album.id,
Expand All @@ -149,9 +133,9 @@ struct ExploreView: View {
trackCount: album.trackCount,
author: Artist.inline(name: album.artistsDisplay, namespace: "album-artist")
)
self.navigationPath.append(playlist)
self.musicNavigation.exploreNavigationPath.append(playlist)
case let .artist(artist):
self.navigationPath.append(artist)
self.musicNavigation.exploreNavigationPath.append(artist)
}
}
}
Expand Down
24 changes: 4 additions & 20 deletions Sources/Kaset/Views/HistoryView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@ import SwiftUI
/// Fetches history from the API and displays songs grouped by time period.
struct HistoryView: View {
@State var viewModel: HistoryViewModel
@Environment(MusicNavigationCoordinator.self) private var musicNavigation
@Environment(PlayerService.self) private var playerService
@Environment(FavoritesManager.self) private var favoritesManager
@State private var navigationPath = NavigationPath()
@State private var networkMonitor = NetworkMonitor.shared
@State private var isRefreshing = false

var body: some View {
NavigationStack(path: self.$navigationPath) {
NavigationStack(path: self.musicNavigation.navigationPathBinding(for: .history)) {
Group {
if !self.networkMonitor.isConnected {
ErrorView(
Expand Down Expand Up @@ -51,16 +51,7 @@ struct HistoryView: View {
.disabled(self.isRefreshing)
}
}
.navigationDestinations(
client: self.viewModel.client,
playerBarNavigationAction: self.playerBarNavigationAction
)
.playerBarMusicNavigation(path: self.$navigationPath)
}
.playerBarMusicNavigation(path: self.$navigationPath)
.safeAreaInset(edge: .bottom, spacing: 0) {
PlayerBar()
.playerBarMusicNavigation(path: self.$navigationPath)
.navigationDestinations(client: self.viewModel.client)
}
.task {
if self.viewModel.loadingState == .idle {
Expand All @@ -77,14 +68,7 @@ struct HistoryView: View {
.refreshable {
await self.performRefresh()
}
.popsNavigationStackOnSidebarReselect(path: self.$navigationPath, for: .history)
}

private var playerBarNavigationAction: PlayerBarNavigationAction {
PlayerBarNavigationAction(
openArtist: { self.navigationPath.append($0) },
openAlbum: { self.navigationPath.append($0) }
)
.popsNavigationStackOnSidebarReselect(path: self.musicNavigation.navigationPathBinding(for: .history), for: .history)
}

/// Refreshes with visual feedback: spinning icon → data swap.
Expand Down
Loading
Loading