diff --git a/App/StarryPlayer/Resources/Localization/Localizable.xcstrings b/App/StarryPlayer/Resources/Localization/Localizable.xcstrings index c753ba8..dd69ff6 100644 --- a/App/StarryPlayer/Resources/Localization/Localizable.xcstrings +++ b/App/StarryPlayer/Resources/Localization/Localizable.xcstrings @@ -1164,6 +1164,54 @@ } } } + }, + "home.recommendedPlaylists": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "推荐歌单" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Recommended Playlists" + } + } + } + }, + "shelf.refresh": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "刷新%@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Refresh %@" + } + } + } + }, + "home.recommendedPlaylists.emptyRefresh": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "暂时没有新的推荐歌单" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No new playlist recommendations right now" + } + } + } } }, "version": "1.0" diff --git a/App/StarryPlayer/Sources/App/AppModel.swift b/App/StarryPlayer/Sources/App/AppModel.swift index 23a6a43..e385c88 100644 --- a/App/StarryPlayer/Sources/App/AppModel.swift +++ b/App/StarryPlayer/Sources/App/AppModel.swift @@ -566,14 +566,36 @@ final class AppModel { } func loadHome() async throws -> HomeContent { + try Task.checkCancellation() let key = homeKey let generation = homeCacheGeneration let content = try await fetchHome(key) try Task.checkCancellation() - if generation == homeCacheGeneration { homeCache = (content, key, Date()) } + guard homeKey == key, homeCacheGeneration == generation else { throw CancellationError() } + homeCache = (content, key, Date()) return content } + func refreshHomeRecommendations() async throws -> [Playlist] { + try Task.checkCancellation() + let key = homeKey + let source = try require(key.source, as: (any RecommendationSource).self, "推荐歌单") + // A full-page load must not restore the previous batch after this refresh. + homeCacheGeneration += 1 + let generation = homeCacheGeneration + let playlists = try await source.recommendedPlaylists(refresh: true) + try Task.checkCancellation() + guard homeKey == key, homeCacheGeneration == generation else { throw CancellationError() } + guard !playlists.isEmpty else { return [] } + // Full loads started during the refresh may still contain the old batch. + homeCacheGeneration += 1 + if var cached = homeCache, cached.key == key { + cached.content.recommended = playlists + homeCache = cached + } + return playlists + } + private func fetchHome(_ key: HomeKey) async throws -> HomeContent { var content = HomeContent() if let source = self.source(key.source, as: (any RecommendationSource).self) { diff --git a/App/StarryPlayer/Sources/Components/Shelf.swift b/App/StarryPlayer/Sources/Components/Shelf.swift index 7e79f1b..2154108 100644 --- a/App/StarryPlayer/Sources/Components/Shelf.swift +++ b/App/StarryPlayer/Sources/Components/Shelf.swift @@ -6,11 +6,13 @@ import SwiftUI /// Index-based placement avoids NSScrollView updates during shell animations; only nearby cards /// exist. struct Shelf: View { - var title: String + var title: DisplayText var items: [Item] var minCardWidth: CGFloat = 156 var spacing: CGFloat = 18 var moreAction: (() -> Void)? = nil + var refreshAction: (() -> Void)? = nil + var isRefreshing = false @ViewBuilder var card: (Item) -> Card @Environment(\.isPageActive) private var pageActive @Environment(\.accessibilityReduceMotion) private var reduceMotion @@ -30,7 +32,8 @@ struct Shelf: View { let padding = Metrics.pagePadding, minWidth = minCardWidth, gap = spacing let lower = max(start - columns - 1, 0), upper = min(start + 2 * columns + 1, items.count) VStack(alignment: .leading, spacing: 12) { - ShelfHeader(title: title, moreAction: moreAction, paging: paging, showsPaging: hovering) + ShelfHeader(title: title, moreAction: moreAction, paging: paging, showsPaging: hovering, + refreshAction: refreshAction, isRefreshing: isRefreshing) .padding(.horizontal, padding) ShelfRow(columns: columns, spacing: spacing, inset: padding, start: start, offset: displayedDrag) { ForEach(Array(items[lower..: View { } } +extension Shelf { + init(title: String, items: [Item], minCardWidth: CGFloat = 156, spacing: CGFloat = 18, + moreAction: (() -> Void)? = nil, refreshAction: (() -> Void)? = nil, isRefreshing: Bool = false, + @ViewBuilder card: @escaping (Item) -> Card) { + self.init(title: .verbatim(title), items: items, minCardWidth: minCardWidth, spacing: spacing, + moreAction: moreAction, refreshAction: refreshAction, isRefreshing: isRefreshing, card: card) + } +} + /// Places a shelf's cards side by side at `columns` to the width, the card with index `start` /// at the leading inset, shifted by `offset`. Each card carries its index (`Index`), so the /// shelf can hand over only the cards near the visible ones. @@ -246,10 +258,13 @@ struct ShelfHeader: View { var forward: () -> Void } - var title: String + var title: DisplayText var moreAction: (() -> Void)? = nil var paging: Paging? = nil var showsPaging = false + var refreshAction: (() -> Void)? = nil + var isRefreshing = false + @Environment(LocalizationStore.self) private var localization @Environment(\.theme) private var theme @State private var titleHovering = false @@ -267,13 +282,28 @@ struct ShelfHeader: View { .allowsHitTesting(showsPaging) .animation(Motion.hover, value: showsPaging) } + if let refreshAction { + Button(action: refreshAction) { + Image(systemName: "arrow.clockwise") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(theme.onSurfaceVariant) + .opacity(isRefreshing ? 0 : 1) + .frame(width: 28, height: 28) + .overlay { if isRefreshing { ProgressView().controlSize(.mini) } } + } + .buttonStyle(VariantButtonStyle(variant: .ghost, isCircle: true)) + .disabled(isRefreshing) + .help(localization.string(LocalizedStringResource("shelf.refresh", defaultValue: "刷新\(localization.string(title))"))) + .accessibilityLabel(localization.string(LocalizedStringResource("shelf.refresh", defaultValue: "刷新\(localization.string(title))"))) + .accessibilityValue(isRefreshing ? localization.string(LocalizedStringResource("common.loading", defaultValue: "加载中…")) : "") + } } .frame(height: 28) } @ViewBuilder private var titleView: some View { - let text = Text(title) + let text = DisplayTextView(text: title) .font(.system(size: 19, weight: .bold)) .foregroundStyle(theme.onSurface) if let moreAction { @@ -307,6 +337,14 @@ struct ShelfHeader: View { } } +extension ShelfHeader { + init(title: String, moreAction: (() -> Void)? = nil, paging: Paging? = nil, showsPaging: Bool = false, + refreshAction: (() -> Void)? = nil, isRefreshing: Bool = false) { + self.init(title: .verbatim(title), moreAction: moreAction, paging: paging, showsPaging: showsPaging, + refreshAction: refreshAction, isRefreshing: isRefreshing) + } +} + struct TrackShelf: View { var title: String var tracks: [Track] diff --git a/App/StarryPlayer/Sources/Pages/HomePage.swift b/App/StarryPlayer/Sources/Pages/HomePage.swift index 8da44aa..4ab8820 100644 --- a/App/StarryPlayer/Sources/Pages/HomePage.swift +++ b/App/StarryPlayer/Sources/Pages/HomePage.swift @@ -10,6 +10,8 @@ struct HomePage: View { @State private var appeared = false @State private var backdrop: PageBackdrop @State private var loadedKey: AppModel.HomeKey? + @State private var recommendationRefreshTask: Task? + @State private var recommendationRequest = 0 init(cached: HomeContent? = nil) { _state = State(initialValue: cached.map { .loaded($0) } ?? .idle) @@ -45,8 +47,13 @@ struct HomePage: View { } } .preference(key: PageBackdropKey.self, value: backdrop) - .task(id: model.homeKey) { await load() } + .task(id: model.homeKey) { + guard !Task.isCancelled else { return } + cancelRecommendationRefresh() + await load() + } .onAppear { appeared = true } + .onDisappear { cancelRecommendationRefresh() } } private var header: some View { @@ -138,7 +145,9 @@ struct HomePage: View { } .frame(maxWidth: .infinity, minHeight: 320) case .loaded(let content): - HomeSections(content: content, backdrop: backdrop) + HomeSections(content: content, backdrop: backdrop, + isRefreshingRecommendations: recommendationRefreshTask != nil, + refreshRecommendations: refreshRecommendations) } } @@ -146,22 +155,63 @@ struct HomePage: View { /// Recent content (another visit to Home a moment ago) is used as is. Another source or /// account starts from the skeleton. private func load() async { + guard !Task.isCancelled else { return } + let key = model.homeKey if let cached = model.cachedHome() { if state.value != cached { state = .loaded(cached) } + loadedKey = key return } - if state.value == nil || loadedKey != model.homeKey { state = .loading } + if state.value == nil || loadedKey != key { state = .loading } + do { + let content = try await model.loadHome() + guard !Task.isCancelled, model.homeKey == key else { return } + loadedKey = key + state = .loaded(content) + } catch { + guard !Task.isCancelled, model.homeKey == key, !(error is CancellationError) else { return } + loadedKey = key + state = .failed(ErrorText.message(error)) + } + } + + private func refreshRecommendations() { + guard recommendationRefreshTask == nil else { return } let key = model.homeKey - let result = await Loadable.run { try await model.loadHome() } - guard !Task.isCancelled else { return } - loadedKey = key - state = result + recommendationRequest += 1 + let request = recommendationRequest + recommendationRefreshTask = Task { + defer { if recommendationRequest == request { recommendationRefreshTask = nil } } + guard !Task.isCancelled, model.homeKey == key, recommendationRequest == request else { return } + do { + let playlists = try await model.refreshHomeRecommendations() + guard !Task.isCancelled, model.homeKey == key, recommendationRequest == request, + case .loaded(var content) = state else { return } + guard !playlists.isEmpty else { + model.showToast(.localized(LocalizedStringResource("home.recommendedPlaylists.emptyRefresh", defaultValue: "暂时没有新的推荐歌单"))) + return + } + content.recommended = playlists + state = .loaded(content) + } catch { + guard !Task.isCancelled, model.homeKey == key, recommendationRequest == request, !(error is CancellationError) else { return } + model.showToast(ErrorText.message(error)) + } + } + } + + private func cancelRecommendationRefresh() { + recommendationRequest += 1 + recommendationRefreshTask?.cancel() + recommendationRefreshTask = nil } } private struct HomeSections: View { var content: HomeContent var backdrop: PageBackdrop + var isRefreshingRecommendations: Bool + var refreshRecommendations: () -> Void @Environment(AppModel.self) private var model @State private var shown = false @@ -187,9 +237,12 @@ private struct HomeSections: View { .homeSection(shown, index: 1) } if !content.recommended.isEmpty { - Shelf(title: "推荐歌单", items: content.recommended) { playlist in + Shelf(title: .localized(LocalizedStringResource("home.recommendedPlaylists", defaultValue: "推荐歌单")), + items: content.recommended, refreshAction: refreshRecommendations, + isRefreshing: isRefreshingRecommendations) { playlist in Color.clear.playlistCard(playlist, model: model) } + .id(content.recommended.map(\.id)) .homeSection(shown, index: 2) } if !content.newSongs.isEmpty { diff --git a/Packages/StarryKit/Sources/MusicSources/MusicSource.swift b/Packages/StarryKit/Sources/MusicSources/MusicSource.swift index 2c1bc51..5284a94 100644 --- a/Packages/StarryKit/Sources/MusicSources/MusicSource.swift +++ b/Packages/StarryKit/Sources/MusicSources/MusicSource.swift @@ -457,6 +457,8 @@ public extension CommentSource { /// empty shelf is not shown. public protocol RecommendationSource: MusicSource { func recommendedPlaylists() async throws -> [Playlist] + /// A user-requested refresh can bypass the source's cached recommendations. + func recommendedPlaylists(refresh: Bool) async throws -> [Playlist] func newSongs() async throws -> [Track] func newAlbums(page: Page) async throws -> [Album] func topArtists(page: Page) async throws -> [Artist] @@ -464,6 +466,7 @@ public protocol RecommendationSource: MusicSource { public extension RecommendationSource { func recommendedPlaylists() async throws -> [Playlist] { [] } + func recommendedPlaylists(refresh: Bool) async throws -> [Playlist] { try await recommendedPlaylists() } func newSongs() async throws -> [Track] { [] } func newAlbums(page: Page) async throws -> [Album] { [] } func topArtists(page: Page) async throws -> [Artist] { [] } diff --git a/Packages/StarryKit/Sources/PluginHost/PluginSource.swift b/Packages/StarryKit/Sources/PluginHost/PluginSource.swift index f66b961..aac6528 100644 --- a/Packages/StarryKit/Sources/PluginHost/PluginSource.swift +++ b/Packages/StarryKit/Sources/PluginHost/PluginSource.swift @@ -231,8 +231,12 @@ public final class PluginSource: MusicSource, SearchableSource, CatalogSource, R } public func recommendedPlaylists() async throws -> [Playlist] { + try await recommendedPlaylists(refresh: false) + } + + public func recommendedPlaylists(refresh: Bool) async throws -> [Playlist] { guard plugin.has("source.recommendedPlaylists") else { return [] } - let playlists: [WirePlaylist] = try await plugin.call("source.recommendedPlaylists") + let playlists: [WirePlaylist] = try await plugin.call("source.recommendedPlaylists", refresh) return playlists.map { $0.playlist(source: id) } } diff --git a/docs/plugin-development.md b/docs/plugin-development.md index 55b2a02..5e257fd 100644 --- a/docs/plugin-development.md +++ b/docs/plugin-development.md @@ -505,7 +505,7 @@ const settings: SettingsSection[] = [{ | 歌手分页内容 | `artistSongs`、`artistAlbums`、`similarArtists` | `artistSongOrders` 声明支持 `hot` / `time` 排序 | | 综合搜索 | `searchOverview` | 返回最佳匹配及各类前几项;不实现时由宿主组合分类搜索 | | 搜索辅助 | `searchSuggestions`、`trendingSearches`、`searchHints` | 按前缀建议、热搜、搜索提示分别提供 | -| 首页内容 | `homeShelves`、`recommendedPlaylists`、`newSongs`、`newAlbums`、`topArtists` | 每个自定义货架只能含一种数据:歌曲、专辑、歌手或歌单;空货架不显示 | +| 首页内容 | `homeShelves`、`recommendedPlaylists`、`newSongs`、`newAlbums`、`topArtists` | 每个自定义货架只能含一种数据:歌曲、专辑、歌手或歌单;空货架不显示;`recommendedPlaylists(true)` 表示用户主动刷新,应绕过推荐缓存 | | 喜欢和收藏 | `likedTrackIDs`、`setLiked`、`setCollected` | 喜欢的歌曲按最新在前返回;收藏类型由 `collectableKinds` 声明 | | 用户歌单 | `userPlaylists`、`likedPlaylistID` | 自己创建的列表标 `isOwned`,有喜欢歌单时将其放在前面 | | 歌单编辑 | `createPlaylist`、`editPlaylist`、`deletePlaylist`、`addToPlaylist`、`removeFromPlaylist`、`reorderPlaylist` | `playlistOptions` 声明描述、隐私、名字长度等能力 | diff --git a/plugins/netease/src/catalog.ts b/plugins/netease/src/catalog.ts index 5333e48..55d5869 100644 --- a/plugins/netease/src/catalog.ts +++ b/plugins/netease/src/catalog.ts @@ -226,18 +226,27 @@ export async function playlist(id: string): Promise { const HOMEPAGE_EXT_INFO = '{"abInfo":{"hp-new-homepageV3.1":"t3"}}'; -function homeBlocks(codes: string[]): Promise { +function homeBlocks(codes: string[], refresh: boolean): Promise { const cursor = orderedJSON([ ['offset', 0], ['blockCodeOrderList', codes], ]); - return request(ep(Discovery.homeBlocks, { cache: 60 }), { cursor, extInfo: HOMEPAGE_EXT_INFO, newStyle: true }); + return request(ep(Discovery.homeBlocks, { cache: null }), { cursor, extInfo: HOMEPAGE_EXT_INFO, newStyle: true, refresh }); } -export async function recommendedPlaylists(): Promise { - const json = await homeBlocks(['HOMEPAGE_BLOCK_PLAYLIST_RCMD']); - const block = array(json?.data?.blocks).find((b) => str(b?.blockCode) === 'HOMEPAGE_BLOCK_PLAYLIST_RCMD'); - return compact(array(block?.creatives).map(map.creativePlaylist)); +export async function recommendedPlaylists(refresh = false): Promise { + const json = await homeBlocks(['HOMEPAGE_BLOCK_PLAYLIST_RCMD'], refresh); + const code = int(json?.code); + if (code !== 200) throw businessError(code ?? -1, str(json?.message) ?? str(json?.msg)); + const blocks = json?.data?.blocks; + if (!Array.isArray(blocks)) throw starry.error('invalidResponse', '网易云的首页响应缺少 data.blocks'); + const block = blocks.find((b) => str(b?.blockCode) === 'HOMEPAGE_BLOCK_PLAYLIST_RCMD'); + if (!block) return []; + const creatives = block.creatives; + if (!Array.isArray(creatives)) throw starry.error('invalidResponse', '网易云的推荐歌单响应缺少 creatives'); + const playlists = uniqueBy(compact(creatives.map(map.creativePlaylist)), (playlist) => playlist.id); + if (creatives.length && !playlists.length) throw starry.error('invalidResponse', '网易云的推荐响应没有可解析的歌单'); + return playlists; } export async function newSongs(): Promise { diff --git a/plugins/sdk/starry.d.ts b/plugins/sdk/starry.d.ts index 22dcd46..e74100e 100644 --- a/plugins/sdk/starry.d.ts +++ b/plugins/sdk/starry.d.ts @@ -315,7 +315,8 @@ export interface Source { * after the shelves above: each one kind of item; empty ones are left out. */ homeShelves?(): Promise; - recommendedPlaylists?(): Promise; + /** `refresh` requests a new batch instead of a cached recommendation response. */ + recommendedPlaylists?(refresh?: boolean): Promise; newSongs?(): Promise; newAlbums?(page: Page): Promise; topArtists?(page: Page): Promise;