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
32 changes: 32 additions & 0 deletions Sources/Kaset/Views/HoverUnderlineNavigationLink.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import SwiftUI

// MARK: - HoverUnderlineNavigationLink

/// A navigation link that underlines its label on hover.
///
/// Used for artist names and other inline navigation targets in track rows and headers.
struct HoverUnderlineNavigationLink<Value: Hashable>: View {
let value: Value
let title: String
var font: Font = .subheadline
var foregroundStyle: Color = .secondary

@State private var isHovering = false

var body: some View {
NavigationLink(value: self.value) {
Text(self.title)
.font(self.font)
.foregroundStyle(self.foregroundStyle)
.underline(self.isHovering)
.lineLimit(1)
.padding(.vertical, 2)
.contentShape(.rect)
}
.buttonStyle(.plain)
.pointerStyle(.link)
.onHover { hovering in
self.isHovering = hovering
}
}
}
116 changes: 16 additions & 100 deletions Sources/Kaset/Views/PlaylistDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,7 @@ struct PlaylistDetailView: View {
index: index,
isAlbum: isAlbum,
subtitle: self.trackArtistsDisplay(for: track, fallbackAuthor: author),
artists: self.trackArtists(for: track, fallbackAuthor: author),
allowsLikeActions: self.hasPersonalAccount,
onPlay: {
self.playTrackInQueue(
Expand Down Expand Up @@ -336,6 +337,21 @@ struct PlaylistDetailView: View {
return fallbackArtist
}

private func trackArtists(for track: Song, fallbackAuthor: String?) -> [Artist]? {
let artists = self.uniqueArtists(from: track.artists)
if !artists.isEmpty {
return artists
}

guard let fallbackName = self.cleanedArtistName(fallbackAuthor),
let author = self.cleanedArtist(self.viewModel.playlistDetail?.author),
author.hasNavigableId,
author.name == fallbackName
else { return nil }

return [author]
}

private func uniqueArtists(from artists: [Artist]) -> [Artist] {
var seen = Set<String>()
var uniqueArtists: [Artist] = []
Expand Down Expand Up @@ -740,106 +756,6 @@ struct PlaylistDetailView: View {
}
}

// MARK: - PlaylistTrackRow

@available(macOS 26.0, *)
private struct PlaylistTrackRow<Menu: View>: View {
let track: Song
let index: Int
let isAlbum: Bool
let subtitle: String?
let allowsLikeActions: Bool
let onPlay: () -> Void
@ViewBuilder let menu: () -> Menu

@State private var isHovered: Bool = false
@Environment(PlayerService.self) private var playerService

var body: some View {
let isCurrent = self.playerService.currentTrack?.videoId == self.track.videoId

Button(action: self.onPlay) {
HStack(spacing: 12) {
Group {
if isCurrent {
NowPlayingIndicator(isPlaying: self.playerService.isPlaying, size: 14)
} else {
Text("\(self.index + 1)")
.font(.system(size: 14))
.foregroundStyle(.secondary)
}
}
.frame(width: 28, alignment: .trailing)

if !self.isAlbum {
CachedAsyncImage(url: self.track.thumbnailURL, targetSize: CGSize(width: 40, height: 40)) { image in
image.resizable().aspectRatio(contentMode: .fill)
} placeholder: {
Rectangle().fill(.quaternary)
}
.frame(width: 40, height: 40)
.clipShape(.rect(cornerRadius: 4))
}

VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(self.track.title)
.font(.system(size: 14))
.foregroundStyle(isCurrent ? .red : .primary)
.lineLimit(1)
if self.track.isExplicit == true {
ExplicitBadge()
}
}
if let subtitle = self.subtitle {
Text(subtitle)
.font(.system(size: 12))
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
.frame(maxWidth: .infinity, alignment: .leading)

LikeButton(song: self.track, isRowHovered: self.isHovered, allowsActions: self.allowsLikeActions)

Text(self.track.durationDisplay)
.font(.system(size: 12))
.foregroundStyle(.secondary)
.frame(width: 45, alignment: .trailing)
}
.padding(.vertical, 8)
.padding(.horizontal, 4)
.contentShape(Rectangle())
.opacity(self.track.isPlayable ? 1 : 0.5)
}
.buttonStyle(.interactiveRow(cornerRadius: 6))
.disabled(!self.track.isPlayable)
.onHover { hovering in self.isHovered = hovering }
.contextMenu { self.menu() }
}
}

// MARK: - HoverUnderlineNavigationLink

private struct HoverUnderlineNavigationLink<Value: Hashable>: View {
let value: Value
let title: String

@State private var isHovering = false

var body: some View {
NavigationLink(value: self.value) {
Text(self.title)
.font(.subheadline)
.underline(self.isHovering)
}
.buttonStyle(.plain)
.onHover { hovering in
self.isHovering = hovering
}
}
}

// MARK: - HeaderArtistLinkLabel

private struct HeaderArtistLinkLabel: View {
Expand Down
127 changes: 127 additions & 0 deletions Sources/Kaset/Views/PlaylistTrackRow.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import SwiftUI

// MARK: - PlaylistTrackRow

/// A single track row in a playlist or album detail view.
///
/// Shows track number/now-playing indicator, thumbnail (for non-album views),
/// title, artist links or subtitle, like button, and duration.
/// Artist names are rendered as clickable navigation links when they have
/// navigable IDs; non-navigable artists fall back to plain text.
@available(macOS 26.0, *)
struct PlaylistTrackRow<Menu: View>: View {
let track: Song
let index: Int
let isAlbum: Bool
let subtitle: String?
let artists: [Artist]?
let allowsLikeActions: Bool
let onPlay: () -> Void
@ViewBuilder let menu: () -> Menu

@State private var isHovered: Bool = false
@Environment(PlayerService.self) private var playerService

var body: some View {
let isCurrent = self.playerService.currentTrack?.videoId == self.track.videoId

Button(action: self.onPlay) {
HStack(spacing: 12) {
Group {
if isCurrent {
NowPlayingIndicator(isPlaying: self.playerService.isPlaying, size: 14)
} else {
Text("\(self.index + 1)")
.font(.system(size: 14))
.foregroundStyle(.secondary)
}
}
.frame(width: 28, alignment: .trailing)

if !self.isAlbum {
CachedAsyncImage(url: self.track.thumbnailURL, targetSize: CGSize(width: 40, height: 40)) { image in
image.resizable().aspectRatio(contentMode: .fill)
} placeholder: {
Rectangle().fill(.quaternary)
}
.frame(width: 40, height: 40)
.clipShape(.rect(cornerRadius: 4))
}

VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(self.track.title)
.font(.system(size: 14))
.foregroundStyle(isCurrent ? .red : .primary)
.lineLimit(1)
if self.track.isExplicit == true {
ExplicitBadge()
}
}
if let subtitle = self.subtitle {
if self.hasNavigableArtists {
self.artistLinksView
} else {
Text(subtitle)
.font(.system(size: 12))
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)

LikeButton(song: self.track, isRowHovered: self.isHovered, allowsActions: self.allowsLikeActions)

Text(self.track.durationDisplay)
.font(.system(size: 12))
.foregroundStyle(.secondary)
.frame(width: 45, alignment: .trailing)
}
.padding(.vertical, 8)
.padding(.horizontal, 4)
.contentShape(Rectangle())
.opacity(self.track.isPlayable ? 1 : 0.5)
}
.buttonStyle(.interactiveRow(cornerRadius: 6))
.disabled(!self.track.isPlayable)
.onHover { hovering in self.isHovered = hovering }
.contextMenu { self.menu() }
}

private var hasNavigableArtists: Bool {
self.artists?.contains(where: \.hasNavigableId) ?? false
}

@ViewBuilder
private var artistLinksView: some View {
let artists = self.artists ?? []

HStack(spacing: 0) {
ForEach(Array(artists.enumerated()), id: \.offset) { index, artist in
if artist.hasNavigableId {
HoverUnderlineNavigationLink(
value: artist,
title: artist.name,
font: .system(size: 12),
foregroundStyle: .secondary
)
} else {
Text(artist.name)
.font(.system(size: 12))
.foregroundStyle(.secondary)
.lineLimit(1)
}

if index < artists.count - 1 {
Text(verbatim: ", ")
.font(.system(size: 12))
.foregroundStyle(.secondary)
.lineLimit(1)
}
}

Spacer(minLength: 0)
}
}
}
13 changes: 13 additions & 0 deletions Tests/KasetTests/Helpers/MockYTMusicClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ final class MockYTMusicClient: YTMusicClientProtocol { // swiftlint:disable:this
var editSongLibraryStatusErrors: [(any Error)?] = []
var getSongDelay: Duration?
var getHistoryDelay: Duration?
var shouldWaitForGetHistoryResponse = false
var getPodcastsDelay: Duration?
var getPlaylistDelay: Duration?
var getPlaylistError: Error?
Expand Down Expand Up @@ -302,6 +303,7 @@ final class MockYTMusicClient: YTMusicClientProtocol { // swiftlint:disable:this
private(set) var addSongToPlaylistCalls: [AddSongToPlaylistCall] = []
private(set) var removeSongFromPlaylistCalls: [RemoveSongFromPlaylistCall] = []
private var removeSongFromPlaylistResponseContinuations: [CheckedContinuation<Void, Never>] = []
private var getHistoryResponseContinuations: [CheckedContinuation<Void, Never>] = []
private(set) var unsubscribeFromPlaylistCalled = false
private(set) var unsubscribeFromPlaylistIds: [String] = []
private(set) var subscribeToArtistCalled = false
Expand Down Expand Up @@ -472,6 +474,11 @@ final class MockYTMusicClient: YTMusicClientProtocol { // swiftlint:disable:this
func getHistory() async throws -> HomeResponse {
self.getHistoryCallCount += 1
self._historyContinuationIndex = 0
if self.shouldWaitForGetHistoryResponse {
await withCheckedContinuation { continuation in
self.getHistoryResponseContinuations.append(continuation)
}
}
if let getHistoryDelay {
try? await Task.sleep(for: getHistoryDelay)
}
Expand Down Expand Up @@ -1101,6 +1108,11 @@ final class MockYTMusicClient: YTMusicClientProtocol { // swiftlint:disable:this
}
}

func resumeNextGetHistoryResponse() {
guard !self.getHistoryResponseContinuations.isEmpty else { return }
self.getHistoryResponseContinuations.removeFirst().resume()
}

func resumeNextRemoveSongFromPlaylistResponse() {
guard !self.removeSongFromPlaylistResponseContinuations.isEmpty else { return }
self.removeSongFromPlaylistResponseContinuations.removeFirst().resume()
Expand Down Expand Up @@ -1437,6 +1449,7 @@ final class MockYTMusicClient: YTMusicClientProtocol { // swiftlint:disable:this
self.editSongLibraryStatusErrors = []
self.getSongDelay = nil
self.getHistoryDelay = nil
self.shouldWaitForGetHistoryResponse = false
self.mixQueueDelay = nil
self.getRadioQueueDelay = nil
self.mixQueueResult = RadioQueueResult(songs: [], continuationToken: nil)
Expand Down
4 changes: 3 additions & 1 deletion Tests/KasetTests/HistoryViewModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -180,13 +180,15 @@ struct HistoryViewModelTests {
await self.viewModel.loadMore()
#expect(self.viewModel.sections.map(\.title) == ["Today", "Yesterday"])

self.mockClient.getHistoryDelay = .milliseconds(100)
self.mockClient.shouldWaitForGetHistoryResponse = true
let refreshTask = Task { await self.viewModel.refresh() }
await self.waitForHistoryRefresh {
self.mockClient.getHistoryCallCount == 2
}

await self.viewModel.loadMore()

self.mockClient.resumeNextGetHistoryResponse()
_ = await refreshTask.value

#expect(self.mockClient.getHistoryContinuationCallCount == 1)
Expand Down
Loading