Skip to content
Merged
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
48 changes: 48 additions & 0 deletions App/StarryPlayer/Resources/Localization/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
24 changes: 23 additions & 1 deletion App/StarryPlayer/Sources/App/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
46 changes: 42 additions & 4 deletions App/StarryPlayer/Sources/Components/Shelf.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ import SwiftUI
/// Index-based placement avoids NSScrollView updates during shell animations; only nearby cards
/// exist.
struct Shelf<Item: Identifiable, Card: View>: 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
Expand All @@ -30,7 +32,8 @@ struct Shelf<Item: Identifiable, Card: View>: 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..<upper].enumerated()), id: \.element.id) { offset, item in
Expand Down Expand Up @@ -104,6 +107,15 @@ struct Shelf<Item: Identifiable, Card: View>: 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.
Expand Down Expand Up @@ -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

Expand All @@ -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 {
Expand Down Expand Up @@ -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]
Expand Down
69 changes: 61 additions & 8 deletions App/StarryPlayer/Sources/Pages/HomePage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Void, Never>?
@State private var recommendationRequest = 0

init(cached: HomeContent? = nil) {
_state = State(initialValue: cached.map { .loaded($0) } ?? .idle)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -138,30 +145,73 @@ 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)
}
}

/// Not animated: the page's height changes with it; the sections bring their own entrance.
/// 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

Expand All @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions Packages/StarryKit/Sources/MusicSources/MusicSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -457,13 +457,16 @@ 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]
}

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] { [] }
Expand Down
6 changes: 5 additions & 1 deletion Packages/StarryKit/Sources/PluginHost/PluginSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
}

Expand Down
2 changes: 1 addition & 1 deletion docs/plugin-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 声明描述、隐私、名字长度等能力 |
Expand Down
21 changes: 15 additions & 6 deletions plugins/netease/src/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,18 +226,27 @@ export async function playlist(id: string): Promise<PlaylistDetail> {

const HOMEPAGE_EXT_INFO = '{"abInfo":{"hp-new-homepageV3.1":"t3"}}';

function homeBlocks(codes: string[]): Promise<any> {
function homeBlocks(codes: string[], refresh: boolean): Promise<any> {
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<Playlist[]> {
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<Playlist[]> {
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<Track[]> {
Expand Down
3 changes: 2 additions & 1 deletion plugins/sdk/starry.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,8 @@ export interface Source {
* after the shelves above: each one kind of item; empty ones are left out.
*/
homeShelves?(): Promise<HomeShelf[]>;
recommendedPlaylists?(): Promise<Playlist[]>;
/** `refresh` requests a new batch instead of a cached recommendation response. */
recommendedPlaylists?(refresh?: boolean): Promise<Playlist[]>;
newSongs?(): Promise<Track[]>;
newAlbums?(page: Page): Promise<Album[]>;
topArtists?(page: Page): Promise<Artist[]>;
Expand Down
Loading