diff --git a/Sources/Kaset/Models/Playlist.swift b/Sources/Kaset/Models/Playlist.swift index 15b494ee0..90b56e21f 100644 --- a/Sources/Kaset/Models/Playlist.swift +++ b/Sources/Kaset/Models/Playlist.swift @@ -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). @@ -221,6 +222,7 @@ struct PlaylistDetail: Identifiable { playlist: Playlist, tracks: [Song], duration: String? = nil, + year: String? = nil, libraryTargetId: String? = nil ) { self.id = playlist.id @@ -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 } diff --git a/Sources/Kaset/Services/API/Parsers/ParsingHelpers+Metadata.swift b/Sources/Kaset/Services/API/Parsers/ParsingHelpers+Metadata.swift index e5cd69c35..3737a878e 100644 --- a/Sources/Kaset/Services/API/Parsers/ParsingHelpers+Metadata.swift +++ b/Sources/Kaset/Services/API/Parsers/ParsingHelpers+Metadata.swift @@ -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), diff --git a/Sources/Kaset/Services/API/Parsers/PlaylistParser.swift b/Sources/Kaset/Services/API/Parsers/PlaylistParser.swift index e571487c7..6be094182 100644 --- a/Sources/Kaset/Services/API/Parsers/PlaylistParser.swift +++ b/Sources/Kaset/Services/API/Parsers/PlaylistParser.swift @@ -15,6 +15,7 @@ enum PlaylistParser { var author: Artist? var trackCount: Int? var duration: String? + var year: String? } typealias LibraryAlbumsSource = LibraryContentParser.LibraryAlbumsSource @@ -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) ) } @@ -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) @@ -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 @@ -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 { diff --git a/Sources/Kaset/Services/API/Parsers/SearchResponseParser+Support.swift b/Sources/Kaset/Services/API/Parsers/SearchResponseParser+Support.swift index d4943e0ec..902d9d257 100644 --- a/Sources/Kaset/Services/API/Parsers/SearchResponseParser+Support.swift +++ b/Sources/Kaset/Services/API/Parsers/SearchResponseParser+Support.swift @@ -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] = { diff --git a/Sources/Kaset/ViewModels/PlaylistDetailViewModel.swift b/Sources/Kaset/ViewModels/PlaylistDetailViewModel.swift index 05bb668b1..4db92f3f8 100644 --- a/Sources/Kaset/ViewModels/PlaylistDetailViewModel.swift +++ b/Sources/Kaset/ViewModels/PlaylistDetailViewModel.swift @@ -342,6 +342,7 @@ extension PlaylistDetailViewModel { playlist: playlist, tracks: allTracks, duration: detail.duration, + year: detail.year, libraryTargetId: detail.libraryTargetId ?? self.playlist.libraryTargetId ) } @@ -371,6 +372,7 @@ extension PlaylistDetailViewModel { playlist: playlist, tracks: detail.tracks, duration: detail.duration, + year: detail.year, libraryTargetId: detail.libraryTargetId ?? self.playlist.libraryTargetId ) } @@ -929,6 +931,7 @@ extension PlaylistDetailViewModel { playlist: updatedPlaylist, tracks: tracks, duration: detail.duration, + year: detail.year, libraryTargetId: detail.libraryTargetId ?? self.playlist.libraryTargetId ) } diff --git a/Sources/Kaset/Views/PlaylistDetailView.swift b/Sources/Kaset/Views/PlaylistDetailView.swift index 2b0f64e91..8bd5f356c 100644 --- a/Sources/Kaset/Views/PlaylistDetailView.swift +++ b/Sources/Kaset/Views/PlaylistDetailView.swift @@ -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( @@ -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) @@ -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 { diff --git a/Sources/Kaset/Views/SharedViews/ExpandableDescriptionText.swift b/Sources/Kaset/Views/SharedViews/ExpandableDescriptionText.swift new file mode 100644 index 000000000..b114efcb2 --- /dev/null +++ b/Sources/Kaset/Views/SharedViews/ExpandableDescriptionText.swift @@ -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) + } +} diff --git a/Tests/KasetTests/PlaylistParserTests.swift b/Tests/KasetTests/PlaylistParserTests.swift index 367ed88f4..39b873831 100644 --- a/Tests/KasetTests/PlaylistParserTests.swift +++ b/Tests/KasetTests/PlaylistParserTests.swift @@ -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", @@ -773,7 +778,7 @@ struct PlaylistParserTests { // swiftlint:disable:this type_body_length "flexColumns": [ [ "musicResponsiveListItemFlexColumnRenderer": [ - "text": ["runs": [["text": "2AM"]]], + "text": ["runs": [["text": "1989"]]], ], ], [ @@ -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) } diff --git a/docs/screenshots/album-release-details/album-description-popover.png b/docs/screenshots/album-release-details/album-description-popover.png new file mode 100644 index 000000000..648d1bc8b Binary files /dev/null and b/docs/screenshots/album-release-details/album-description-popover.png differ diff --git a/docs/screenshots/album-release-details/album-metadata-and-description.png b/docs/screenshots/album-release-details/album-metadata-and-description.png new file mode 100644 index 000000000..b7017a39d Binary files /dev/null and b/docs/screenshots/album-release-details/album-metadata-and-description.png differ