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
3 changes: 3 additions & 0 deletions Sources/Kaset/Utilities/AccessibilityIdentifiers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,9 @@ enum AccessibilityID {
static let scrollView = "likedMusicView.scrollView"
static let loadingIndicator = "likedMusicView.loading"
static let emptyState = "likedMusicView.emptyState"
static let searchField = "likedMusicView.searchField"
static let clearSearchButton = "likedMusicView.clearSearchButton"
static let searchEmptyState = "likedMusicView.searchEmptyState"

static func songRow(index: Int) -> String {
"likedMusicView.song.\(index)"
Expand Down
40 changes: 40 additions & 0 deletions Sources/Kaset/ViewModels/PlaylistDetailViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ final class PlaylistDetailViewModel {
/// The loaded playlist detail.
private(set) var playlistDetail: PlaylistDetail?

/// Current live search query for Liked Music tracks.
var likedMusicSearchQuery = ""

/// Whether more tracks are available to load.
private(set) var hasMore: Bool = false

Expand Down Expand Up @@ -73,6 +76,43 @@ final class PlaylistDetailViewModel {
}
}

var hasActiveLikedMusicSearch: Bool {
self.isLikedMusicPlaylist && !self.normalizedLikedMusicSearchQuery.isEmpty
}

var normalizedLikedMusicSearchQuery: String {
self.likedMusicSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
}

func clearLikedMusicSearch() {
self.likedMusicSearchQuery = ""
}

func visibleTracks(for detail: PlaylistDetail) -> [Song] {
guard self.hasActiveLikedMusicSearch else {
return detail.tracks
}

let query = self.normalizedLikedMusicSearchQuery
return detail.tracks.filter { self.song($0, matches: query) }
}

private func song(_ song: Song, matches query: String) -> Bool {
let searchableText = [
song.title,
song.artistsDisplay,
song.album?.title,
song.videoId,
]
.compactMap(\.self)
.joined(separator: " ")

return searchableText.range(
of: query,
options: [.caseInsensitive, .diacriticInsensitive]
) != nil
}

/// Strips song count patterns from author text (e.g., " • 145 songs" or " • 2,429 tracks").
/// Used to clean fallback author values that may contain redundant song counts.
private func stripSongCountAuthor(from author: Artist?) -> Artist? {
Expand Down
2 changes: 0 additions & 2 deletions Sources/Kaset/Views/ArtistDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ 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()
Expand Down Expand Up @@ -132,7 +131,6 @@ struct ArtistDetailView: View { // swiftlint:disable:this type_body_length
// padding above, while horizontal shelves pass `contentInset` so their
// tracks reach the under-sidebar band and slide under the floating glass
// on macOS 26. The accent backdrop (ignores safe area) refracts through.
.topFade(style: .contentMask)
}

private func headerView(_ detail: ArtistDetail) -> some View {
Expand Down
2 changes: 0 additions & 2 deletions Sources/Kaset/Views/ArtistDiscographyView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,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()
Expand Down
2 changes: 0 additions & 2 deletions Sources/Kaset/Views/ArtistEpisodesListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,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()
Expand Down
105 changes: 64 additions & 41 deletions Sources/Kaset/Views/LegacyFallbackViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ struct SimplePlaylistDetailView: View {
@State var viewModel: PlaylistDetailViewModel
@Environment(PlayerService.self) private var playerService
@Environment(SongLikeStatusManager.self) private var likeStatusManager
@FocusState private var isLikedMusicSearchFocused: Bool

init(
playlist: Playlist,
Expand Down Expand Up @@ -77,7 +78,19 @@ struct SimplePlaylistDetailView: View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
self.headerView(detail)
self.trackList(detail.tracks)
let visibleTracks = self.viewModel.visibleTracks(for: detail)
if LikedMusicPlaylist.matches(id: self.playlist.id) {
LikedMusicSearchField(
text: self.$viewModel.likedMusicSearchQuery,
isFocused: self.$isLikedMusicSearchFocused,
isActive: self.viewModel.hasActiveLikedMusicSearch,
style: .fallback
) {
self.viewModel.clearLikedMusicSearch()
self.isLikedMusicSearchFocused = true
}
}
self.trackList(visibleTracks, allTracks: detail.tracks)
}
.padding(.horizontal, 20)
.padding(.top, 20)
Expand Down Expand Up @@ -133,52 +146,62 @@ struct SimplePlaylistDetailView: View {
}
}

private func trackList(_ tracks: [Song]) -> some View {
private func trackList(_ tracks: [Song], allTracks: [Song]? = nil) -> some View {
VStack(spacing: 0) {
ForEach(Array(tracks.enumerated()), id: \.offset) { index, track in
Button {
Task { await self.playFromIndex(index, tracks: tracks) }
} label: {
HStack(spacing: 12) {
Text("\(index + 1)")
.font(.callout.monospacedDigit())
.foregroundStyle(.secondary)
.frame(width: 28, alignment: .trailing)
AsyncImage(url: track.thumbnailURL) { image in
image.resizable().aspectRatio(contentMode: .fill)
} placeholder: {
Color.secondary.opacity(0.15)
}
.frame(width: 36, height: 36)
.clipShape(RoundedRectangle(cornerRadius: 4))

VStack(alignment: .leading, spacing: 2) {
Text(track.title).lineLimit(1)
Text(track.artistsDisplay)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer()
if let dur = track.duration {
Text(self.formatDuration(dur))
.font(.caption.monospacedDigit())
if tracks.isEmpty, self.viewModel.hasActiveLikedMusicSearch {
LikedMusicSearchEmptyState(hasMore: self.viewModel.hasMore, iconSize: 32)
} else {
ForEach(Array(tracks.enumerated()), id: \.offset) { index, track in
Button {
Task { await self.playFromIndex(index, tracks: tracks) }
} label: {
HStack(spacing: 12) {
Text("\(index + 1)")
.font(.callout.monospacedDigit())
.foregroundStyle(.secondary)
.frame(width: 28, alignment: .trailing)
AsyncImage(url: track.thumbnailURL) { image in
image.resizable().aspectRatio(contentMode: .fill)
} placeholder: {
Color.secondary.opacity(0.15)
}
.frame(width: 36, height: 36)
.clipShape(RoundedRectangle(cornerRadius: 4))

VStack(alignment: .leading, spacing: 2) {
Text(track.title).lineLimit(1)
Text(track.artistsDisplay)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer()
if let dur = track.duration {
Text(self.formatDuration(dur))
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 6)
.padding(.horizontal, 8)
.contentShape(Rectangle())
}
.padding(.vertical, 6)
.padding(.horizontal, 8)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(!track.isPlayable)
.opacity(track.isPlayable ? 1 : 0.5)
.onAppear {
if index >= tracks.count - 3, self.viewModel.hasMore {
Task { await self.viewModel.loadMore() }
.buttonStyle(.plain)
.disabled(!track.isPlayable)
.opacity(track.isPlayable ? 1 : 0.5)
.onAppear {
let paginationTracks = allTracks ?? tracks
if index >= tracks.count - 3, self.viewModel.hasMore {
Task { await self.viewModel.loadMore() }
} else if let originalIndex = paginationTracks.firstIndex(where: { $0.videoId == track.videoId }),
originalIndex >= paginationTracks.count - 3,
self.viewModel.hasMore
{
Task { await self.viewModel.loadMore() }
}
}
Divider().opacity(0.2)
}
Divider().opacity(0.2)
}

if self.viewModel.loadingState == .loadingMore {
Expand Down
86 changes: 86 additions & 0 deletions Sources/Kaset/Views/LikedMusicSearchViews.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import SwiftUI

// MARK: - LikedMusicSearchFieldStyle

enum LikedMusicSearchFieldStyle {
case liquidGlass
case fallback
}

// MARK: - LikedMusicSearchField

struct LikedMusicSearchField: View {
let text: Binding<String>
let isFocused: FocusState<Bool>.Binding
let isActive: Bool
let style: LikedMusicSearchFieldStyle
let onClear: () -> Void

var body: some View {
switch self.style {
case .liquidGlass:
self.content
.padding(10)
.compatGlass(in: .capsule)
case .fallback:
self.content
.padding(10)
.background(Color.secondary.opacity(0.12), in: Capsule())
}
}

private var content: some View {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)

TextField(String(localized: "Search liked songs..."), text: self.text)
.textFieldStyle(.plain)
.focused(self.isFocused)
.accessibilityIdentifier(AccessibilityID.LikedMusic.searchField)

if self.isActive {
Button {
self.onClear()
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.accessibilityLabel(String(localized: "Clear liked music search"))
.accessibilityIdentifier(AccessibilityID.LikedMusic.clearSearchButton)
}
}
}
}

// MARK: - LikedMusicSearchEmptyState

struct LikedMusicSearchEmptyState: View {
let hasMore: Bool
var iconSize: CGFloat = 36

var body: some View {
VStack(spacing: 12) {
Image(systemName: "magnifyingglass")
.font(.system(size: self.iconSize))
.foregroundStyle(.tertiary)

Text(self.hasMore ? String(localized: "Still searching liked songs...") : String(localized: "No liked songs found"))
.font(.headline)
.foregroundStyle(.secondary)

Text(
self.hasMore
? String(localized: "More liked songs are loading and results will appear here as they match.")
: String(localized: "Try a different song, artist, or album name.")
)
.font(.subheadline)
.foregroundStyle(.tertiary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 32)
.accessibilityIdentifier(AccessibilityID.LikedMusic.searchEmptyState)
}
}
Loading
Loading