Skip to content

Commit 24e88bc

Browse files
committed
Adds a new song detail view and 30s song preview for tracks from Apple Music.
1 parent ffee4ac commit 24e88bc

File tree

9 files changed

+254
-8
lines changed

9 files changed

+254
-8
lines changed

classes/MusicKit.php

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,96 @@ public static function recentlyPlayed(array $opts, array $params = []): Response
149149
return $result instanceof Response ? $result : Response::json($result ?? ['data' => []], 200);
150150
}
151151

152+
// get individual track details
153+
public static function songDetails(string $songId, string $language = 'en-US'): Response
154+
{
155+
$opts = static::opts();
156+
$dev = Auth::devToken($opts);
157+
158+
// option > user's storefront (if available) > 'us'
159+
$storefront = option('scottboms.applemusic.storefront');
160+
if ($storefront === 'auto' || empty($storefront)) {
161+
$sf = 'us';
162+
$mut = Auth::readAnyToken(); // optional
163+
if ($mut) {
164+
$sfRes = static::appleGet('/v1/me/storefront', $dev, $mut, ['Accept-Language' => $language]);
165+
$sfJson = json_decode($sfRes->body() ?? 'null', true);
166+
167+
if (is_array($sfJson) && !empty($sfJson['data'][0]['id'])) {
168+
$sf = $sfJson['data'][0]['id'];
169+
}
170+
}
171+
$storefront = $sf ?: 'us';
172+
}
173+
174+
// call catalog: dev token only (no music-user-token necessary)
175+
$resp = \Kirby\Http\Remote::get('https://api.music.apple.com/v1/catalog/' . rawurlencode($storefront) . '/songs/' . rawurlencode($songId), [
176+
'headers' => [
177+
'Authorization' => 'Bearer ' . $dev,
178+
'Accept' => 'application/json',
179+
'Accept-Language' => $language,
180+
],
181+
'timeout' => 10,
182+
]);
183+
184+
$code = $resp->code();
185+
$body = json_decode($resp->content() ?? 'null', true);
186+
187+
if ($code >= 400 || !is_array($body)) {
188+
return Response::json([
189+
'status' => 'error',
190+
'message' => 'Apple Music catalog error',
191+
'code' => $code,
192+
'body' => $body,
193+
], $code ?: 500);
194+
}
195+
196+
// normalization for component view
197+
$it = $body['data'][0] ?? null;
198+
$a = $it['attributes'] ?? [];
199+
$img = null;
200+
201+
if (!empty($a['artwork']['url'])) {
202+
$img = str_replace(['{w}','{h}'], [600, 600], $a['artwork']['url']);
203+
}
204+
$seconds = isset($a['durationInMillis']) ? (int) floor($a['durationInMillis'] / 1000) : null;
205+
$duration = is_int($seconds) ? sprintf('%d:%02d', floor($seconds/60), $seconds % 60) : null;
206+
207+
// releaseYear from releaseDate
208+
$releaseDate = $a['releaseDate'] ?? null;
209+
$releaseYear = null;
210+
if ($releaseDate) {
211+
$ts = strtotime($releaseDate);
212+
if ($ts !== false) {
213+
$releaseYear = date('Y', $ts);
214+
}
215+
}
216+
217+
$id = $it['id'] ?? null;
218+
$url = $a['url'] ?? null;
219+
220+
// if the id starts with "i.", clear the url
221+
if (is_string($id) && str_starts_with($id, 'i.')) {
222+
$url = null;
223+
}
224+
225+
return Response::json([
226+
'id' => $id,
227+
'name' => $a['name'] ?? '',
228+
'artistName' => $a['artistName'] ?? '',
229+
'albumName' => $a['albumName'] ?? '',
230+
'composerName' => $a['composerName'] ?? '',
231+
'genreNames' => $a['genreNames'] ?? [],
232+
'releaseDate' => $releaseDate,
233+
'releaseYear' => $releaseYear,
234+
'url' => $url,
235+
'previewUrl' => $a['previews'][0]['url'] ?? null,
236+
'duration' => $duration,
237+
'image' => $img,
238+
'raw' => $body, // optional: full response payload
239+
], 200);
240+
}
241+
152242
/**
153243
* server-side helper for front-end:
154244
* fetches recently played for the shared token, cache it,

index.css

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

index.js

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

index.php

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,21 @@
102102
]
103103
];
104104
}
105-
]
105+
],
106+
107+
[
108+
'pattern' => 'applemusic/song/(:any)',
109+
'action' => function ($songId) {
110+
return [
111+
'component' => 'k-musickit-song-view',
112+
'props' => [
113+
'songId' => $songId,
114+
'language' => option('panel.language', 'en-US')
115+
]
116+
];
117+
}
118+
],
119+
106120
]
107121
]
108122
],

lib/routes.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,22 @@
168168
}
169169
],
170170

171+
// get details for an individual track from the api
172+
[
173+
'pattern' => 'applemusic/song/(:any)',
174+
'method' => 'GET',
175+
'action' => function (string $songId) {
176+
$language = get('l', 'en-US');
177+
// ensure options exist (no user token required for catalog lookups)
178+
$opts = MusicKit::opts();
179+
if ($err = Auth::validateOptions($opts)) {
180+
return $err; // 4xx with structured json
181+
}
182+
$res = MusicKit::songDetails($songId, $language);
183+
return $res;
184+
}
185+
],
186+
171187
// storefront (delegates to MusicKit::storefront)
172188
[
173189
'pattern' => 'applemusic/applemusic',

src/components/MusicKitHistory.vue

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,8 @@ export default {
150150
},
151151
collectionItems() {
152152
return (this.items || []).map((item) => {
153-
const link = this.trackUrl(item);
153+
const appleMusicUrl = this.trackUrl(item); // external Apple Music URL (may be null)
154+
const isInline = typeof item.id === 'string' && item.id.startsWith('i.');
154155
155156
const base = {
156157
id: item.id,
@@ -162,14 +163,14 @@ export default {
162163
cover: true,
163164
back: 'pattern'
164165
},
165-
link,
166166
icon: 'music',
167-
target: '_blank',
168167
title: this.trackSubtitle(item),
168+
// only add the panel link if it's not an inline ("i.") id
169+
...(isInline ? {} : { link: 'applemusic/song/' + item.id })
169170
};
170171
171172
// only add options if link is present
172-
if (link) {
173+
if (appleMusicUrl) {
173174
base.options = [
174175
{
175176
icon: 'headphones',

src/components/MusicKitSong.vue

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
<template>
2+
<k-panel-inside>
3+
<k-view>
4+
<k-header class="k-site-view-header">
5+
{{ song?.name || 'Song' }}
6+
7+
<template #buttons>
8+
<k-button v-if="song?.url" icon="headphones" :link="song.url" target="_blank" theme="blue-icon" variant="filled">Listen in Apple Music</k-button>
9+
</template>
10+
</k-header>
11+
12+
<k-box v-if="loading" icon="loader">Loading...</k-box>
13+
<k-box v-else-if="err" theme="negative" icon="alert">{{ err }}</k-box>
14+
15+
<k-section v-else>
16+
17+
<k-grid style="gap: 0; --columns: 12; background: var(--item-color-back); border-radius: var(--rounded); box-shadow: var(--shadow);">
18+
<k-image-frame v-if="song.image" :src="song.image" :alt="song.name" ratio="1/1" back="pattern" cover="true" icon="music" style="border-radius: var(--rounded); --width: 1/2" />
19+
20+
<k-box style="--width: 1/2">
21+
<div class="k-text" style="padding: var(--spacing-8)">
22+
<p v-if="song.artistName" class="am-songArtist">{{ song.artistName }}</p>
23+
<p v-if="song.albumName" class="am-songAlbum">{{ song.albumName }} ({{ song.releaseYear }})</p>
24+
25+
<k-box v-if="song.duration" icon="clock" class="am-songDuration">{{ song.duration }}</k-box>
26+
27+
<k-box v-if="song.previewUrl">
28+
<audio :src="song.previewUrl" class="k-file-preview am-audioPreview" controls />
29+
</k-box>
30+
31+
<hr />
32+
33+
<k-box v-if="song.composerName" icon="composer" class="am-meta am-metaSmall">Written by {{ song.composerName }}</k-box>
34+
<k-box v-if="song.genreNames?.length" icon="tag" class="am-meta am-metaSmall">{{ song.genreNames.join(', ') }}</k-box>
35+
<k-box v-if="song.releaseDate" icon="calendar" class="am-meta am-metaSmall">{{ song.releaseDate }}</k-box>
36+
</div>
37+
</k-box>
38+
</k-grid>
39+
40+
</k-section>
41+
42+
</k-view>
43+
</k-panel-inside>
44+
</template>
45+
46+
<script>
47+
export default {
48+
name: 'Apple Music - Song Details',
49+
props: {
50+
songId: String,
51+
language: String
52+
},
53+
54+
data() {
55+
return {
56+
loading: true,
57+
err: null,
58+
song: null
59+
};
60+
},
61+
62+
async created() {
63+
try {
64+
const url = `/applemusic/song/${encodeURIComponent(this.songId)}?l=${encodeURIComponent(this.language || 'en-US')}`;
65+
const res = await fetch(url, {
66+
credentials: 'same-origin',
67+
headers: { 'Accept': 'application/json' }
68+
});
69+
if (!res.ok) throw new Error(`HTTP ${res.status}`)
70+
const data = await res.json();
71+
this.song = data;
72+
} catch (e) {
73+
this.err = e?.message || 'Failed to load song'
74+
} finally {
75+
this.loading = false
76+
}
77+
},
78+
79+
methods: {
80+
back() { this.$go('applemusic') }
81+
},
82+
83+
}
84+
</script>
85+
86+
<style>
87+
.am-songArtist {
88+
font-size: var(--text-4xl);
89+
}
90+
91+
.am-songAlbum {
92+
font-size: var(--text-2xl);
93+
margin-top: var(--spacing-2);
94+
}
95+
96+
.am-songAlbum,
97+
.am-songDuration {
98+
color: light-dark(var(--color-gray-650), var(--color-gray-450));
99+
}
100+
101+
.am-songDuration {margin-top: var(--spacing-2);}
102+
103+
.am-songDuration,
104+
.am-songComposer {
105+
font-size: var(--text-lg);
106+
}
107+
108+
.am-audioPreview {
109+
background: none;
110+
margin: var(--spacing-4) 0;
111+
width: 25%;
112+
}
113+
114+
.am-metaSmall {
115+
font-size: var(--text-sm);
116+
}
117+
118+
.am-meta {
119+
color: light-dark(var(--color-gray-500), var(--color-gray-700));
120+
margin-top: var(--spacing-2);
121+
}
122+
</style>

src/icons.js

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import AppleMusicField from "./components/AppleMusicField.vue";
22
import AppleMusicBlock from "./components/AppleMusicBlock.vue";
33
import MusicKitConfig from "./components/MusicKitConfig.vue";
44
import MusicKitHistory from "./components/MusicKitHistory.vue";
5+
import MusicKitSong from "./components/MusicKitSong.vue";
56

67
import { icons } from "./icons.js";
78

@@ -10,6 +11,7 @@ panel.plugin("scottboms/kirby-applemusic", {
1011
components: {
1112
"k-musickit-config-view": MusicKitConfig,
1213
"k-musickit-history-view": MusicKitHistory,
14+
"k-musickit-song-view": MusicKitSong,
1315
},
1416
fields: {
1517
applemusic: AppleMusicField

0 commit comments

Comments
 (0)