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/Models/Playlist.swift
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ struct PlaylistDetail: Identifiable {
let canDelete: Bool
let tracks: [Song]
let duration: String?
let year: String?
let libraryTargetId: String?

/// Whether this is an album (vs a playlist).
Expand All @@ -221,6 +222,7 @@ struct PlaylistDetail: Identifiable {
playlist: Playlist,
tracks: [Song],
duration: String? = nil,
year: String? = nil,
libraryTargetId: String? = nil
) {
self.id = playlist.id
Expand All @@ -232,6 +234,7 @@ struct PlaylistDetail: Identifiable {
self.canDelete = playlist.canDelete
self.tracks = tracks
self.duration = duration
self.year = year
self.libraryTargetId = libraryTargetId ?? playlist.libraryTargetId
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ extension ParsingHelpers {
return self.isStandaloneYear(text)
}

private static func isStandaloneYear(_ text: String) -> Bool {
static func isStandaloneYear(_ text: String) -> Bool {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.count == 4,
trimmed.allSatisfy(\.isNumber),
Expand Down
24 changes: 23 additions & 1 deletion Sources/Kaset/Services/API/Parsers/PlaylistParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ enum PlaylistParser {
var author: Artist?
var trackCount: Int?
var duration: String?
var year: String?
}

typealias LibraryAlbumsSource = LibraryContentParser.LibraryAlbumsSource
Expand Down Expand Up @@ -88,6 +89,7 @@ enum PlaylistParser {
playlist: playlist,
tracks: tracks,
duration: header.duration,
year: playlist.isAlbum ? header.year : nil,
libraryTargetId: Self.extractAlbumLibraryTargetId(from: data, albumId: playlistId)
)
}
Expand All @@ -114,6 +116,7 @@ enum PlaylistParser {
playlist: playlist,
tracks: tracks,
duration: header.duration,
year: playlist.isAlbum ? header.year : nil,
libraryTargetId: Self.extractAlbumLibraryTargetId(from: data, albumId: playlistId)
)
let continuationToken = Self.extractPlaylistContinuationToken(from: data)
Expand Down Expand Up @@ -897,8 +900,21 @@ enum PlaylistParser {
}

private static func isHeaderContentKind(_ text: String) -> Bool {
if self.isAlbumContentKind(text) {
return true
}

return switch text.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
case "playlist", "song", "uploads":
true
default:
false
}
}

private static func isAlbumContentKind(_ text: String) -> Bool {
switch text.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
case "album", "single", "ep", "playlist", "song", "uploads":
case "album", "single", "ep":
true
default:
false
Expand Down Expand Up @@ -933,6 +949,12 @@ enum PlaylistParser {
if header.duration == nil {
header.duration = texts.last(where: Self.isDurationMetadata)
}

if header.year == nil,
texts.contains(where: Self.isAlbumContentKind)
{
header.year = texts.first(where: ParsingHelpers.isStandaloneYear)
}
}

private static func isDurationMetadata(_ text: String) -> Bool {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,7 @@ extension SearchResponseParser {
}

static func isYear(_ text: String) -> Bool {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.count == 4,
trimmed.allSatisfy(\.isNumber),
let year = Int(trimmed)
else {
return false
}
return (1900 ... 2100).contains(year)
ParsingHelpers.isStandaloneYear(text)
}

private static let durationUnitSeconds: [String: TimeInterval] = {
Expand Down
3 changes: 3 additions & 0 deletions Sources/Kaset/ViewModels/PlaylistDetailViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,7 @@ extension PlaylistDetailViewModel {
playlist: playlist,
tracks: allTracks,
duration: detail.duration,
year: detail.year,
libraryTargetId: detail.libraryTargetId ?? self.playlist.libraryTargetId
)
}
Expand Down Expand Up @@ -371,6 +372,7 @@ extension PlaylistDetailViewModel {
playlist: playlist,
tracks: detail.tracks,
duration: detail.duration,
year: detail.year,
libraryTargetId: detail.libraryTargetId ?? self.playlist.libraryTargetId
)
}
Expand Down Expand Up @@ -929,6 +931,7 @@ extension PlaylistDetailViewModel {
playlist: updatedPlaylist,
tracks: tracks,
duration: detail.duration,
year: detail.year,
libraryTargetId: detail.libraryTargetId ?? self.playlist.libraryTargetId
)
}
Expand Down
15 changes: 9 additions & 6 deletions Sources/Kaset/Views/PlaylistDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ struct PlaylistDetailView: View {
title: detail.title,
artists: detail.author.map { [$0] },
thumbnailURL: detail.thumbnailURL,
year: nil,
year: detail.year,
trackCount: detail.trackCount ?? detail.tracks.count
)
self.tracksView(
Expand Down Expand Up @@ -200,6 +200,11 @@ struct PlaylistDetailView: View {
.foregroundStyle(.secondary)
.padding(.top, 4)

if let description = detail.description, !description.isEmpty {
ExpandableDescriptionText(description)
.id(detail.id)
}

Spacer(minLength: 24)

self.headerButtons(detail)
Expand Down Expand Up @@ -238,11 +243,9 @@ struct PlaylistDetailView: View {
}

private func metadataText(for detail: PlaylistDetail) -> String {
if let duration = detail.duration {
return "\(detail.trackCountDisplay) • \(duration)"
}

return detail.trackCountDisplay
[detail.year, detail.trackCountDisplay, detail.duration]
.compactMap(\.self)
.joined(separator: " • ")
}

private func contentKindText(for detail: PlaylistDetail) -> String {
Expand Down
85 changes: 85 additions & 0 deletions Sources/Kaset/Views/SharedViews/ExpandableDescriptionText.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import SwiftUI

/// Readable progressive disclosure for API-provided prose descriptions.
struct ExpandableDescriptionText: View {
private static let collapsedLineLimit = 3
private static let readableWidth: CGFloat = 720
private static let popoverWidth: CGFloat = 560
private static let popoverHeight: CGFloat = 360
private static let truncationTolerance: CGFloat = 0.5

private let text: String

@State private var showsFullDescription = false
@State private var previewHeight: CGFloat = 0
@State private var fullHeight: CGFloat = 0

init(_ text: String) {
self.text = text
}

private var isTruncated: Bool {
self.fullHeight > self.previewHeight + Self.truncationTolerance
}

var body: some View {
VStack(alignment: .leading, spacing: 4) {
self.descriptionText
.lineLimit(Self.collapsedLineLimit)
.fixedSize(horizontal: false, vertical: true)
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: {
self.previewHeight = $0
}
.background {
self.descriptionText
.fixedSize(horizontal: false, vertical: true)
.hidden()
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: {
self.fullHeight = $0
}
}

if self.isTruncated {
Button(String(localized: "More")) {
self.showsFullDescription = true
}
.buttonStyle(.plain)
.font(.callout.weight(.semibold))
.foregroundStyle(.tint)
.popover(isPresented: self.$showsFullDescription) {
self.fullDescriptionPopover
}
}
}
.frame(maxWidth: Self.readableWidth, alignment: .leading)
}

private var descriptionText: some View {
Text(self.text)
.font(.callout)
.foregroundStyle(.secondary)
.lineSpacing(2)
}

private var fullDescriptionPopover: some View {
VStack(alignment: .leading, spacing: 16) {
ScrollView {
self.descriptionText
.frame(maxWidth: .infinity, alignment: .leading)
}

Divider()

HStack {
Spacer()

Button(String(localized: "Done")) {
self.showsFullDescription = false
}
.keyboardShortcut(.defaultAction)
}
}
.padding(20)
.frame(width: Self.popoverWidth, height: Self.popoverHeight)
}
}
16 changes: 12 additions & 4 deletions Tests/KasetTests/PlaylistParserTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -737,11 +737,16 @@ struct PlaylistParserTests { // swiftlint:disable:this type_body_length
"sectionListRenderer": [
"contents": [[
"musicResponsiveHeaderRenderer": [
"title": ["runs": [["text": "2AM"]]],
"subtitle": ["runs": [["text": "Album"], ["text": " • "], ["text": "2026"]]],
"title": ["runs": [["text": "1989"]]],
"subtitle": ["runs": [["text": "Album"], ["text": " • "], ["text": "2014"]]],
"secondSubtitle": [
"runs": [["text": "1 song"], ["text": " • "], ["text": "2 minutes, 42 seconds"]],
],
"description": [
"musicDescriptionShelfRenderer": [
"description": ["runs": [["text": "Album description"]]],
],
],
"straplineTextOne": [
"runs": [[
"text": "Test Artist",
Expand Down Expand Up @@ -773,7 +778,7 @@ struct PlaylistParserTests { // swiftlint:disable:this type_body_length
"flexColumns": [
[
"musicResponsiveListItemFlexColumnRenderer": [
"text": ["runs": [["text": "2AM"]]],
"text": ["runs": [["text": "1989"]]],
],
],
[
Expand All @@ -797,12 +802,15 @@ struct PlaylistParserTests { // swiftlint:disable:this type_body_length
],
]

let detail = PlaylistParser.parsePlaylistDetail(data, playlistId: "MPRE-test-album")
let detail = PlaylistParser.parsePlaylistWithContinuation(data, playlistId: "MPRE-test-album").detail

#expect(detail.isAlbum)
#expect(detail.title == "1989")
#expect(detail.author?.name == "Test Artist")
#expect(detail.author?.id == "UCTESTARTIST")
#expect(detail.duration == "2 minutes, 42 seconds")
#expect(detail.year == "2014")
#expect(detail.description == "Album description")
#expect(detail.tracks.first?.artists.isEmpty == true)
}

Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading