Connect Apple Music and Spotify-via-Last.fm on iPhone
- Local Expo module (Swift): MusicKit authorization, Music User Token via MusicUserTokenProvider, and live Music app now-playing while the app is open (MediaPlayer notifications), reported through the device endpoint - One-tap Apple Music onboarding on iOS; Apple Music row in Settings - Sign in with Last.fm, plus a guided Spotify-via-Last.fm flow when direct Spotify sign-in is capped - Device registration moved to shared code used by Android and iOS Not yet compiled for iOS (no Xcode here); needs an EAS build. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
This commit is contained in:
@@ -9,7 +9,10 @@
|
|||||||
"userInterfaceStyle": "dark",
|
"userInterfaceStyle": "dark",
|
||||||
"ios": {
|
"ios": {
|
||||||
"icon": "./assets/expo.icon",
|
"icon": "./assets/expo.icon",
|
||||||
"bundleIdentifier": "tech.chrisedwards.musicshare"
|
"bundleIdentifier": "tech.chrisedwards.musicshare",
|
||||||
|
"infoPlist": {
|
||||||
|
"NSAppleMusicUsageDescription": "musicshare shows your friends what you play in Apple Music, and opens their songs for you."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"platforms": ["apple"],
|
||||||
|
"apple": {
|
||||||
|
"modules": ["AppleMusicModule"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// iOS-only native module; the default export is null where it is unavailable.
|
||||||
|
export { default } from './src/AppleMusicModule';
|
||||||
|
export * from './src/AppleMusic.types';
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
Pod::Spec.new do |s|
|
||||||
|
s.name = 'AppleMusic'
|
||||||
|
s.version = '1.0.0'
|
||||||
|
s.summary = 'Apple Music authorization, user token and Music app now-playing'
|
||||||
|
s.description = 'Local Expo module for musicshare'
|
||||||
|
s.author = ''
|
||||||
|
s.homepage = 'https://docs.expo.dev/modules/'
|
||||||
|
s.platforms = {
|
||||||
|
:ios => '16.4',
|
||||||
|
:tvos => '16.4'
|
||||||
|
}
|
||||||
|
s.source = { git: '' }
|
||||||
|
s.static_framework = true
|
||||||
|
|
||||||
|
s.dependency 'ExpoModulesCore'
|
||||||
|
s.frameworks = 'MusicKit', 'MediaPlayer'
|
||||||
|
|
||||||
|
# Swift/Objective-C compatibility
|
||||||
|
s.pod_target_xcconfig = {
|
||||||
|
'DEFINES_MODULE' => 'YES',
|
||||||
|
}
|
||||||
|
|
||||||
|
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
|
||||||
|
end
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import ExpoModulesCore
|
||||||
|
import MediaPlayer
|
||||||
|
import MusicKit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apple Music for the iOS app:
|
||||||
|
* - the one-tap "Allow access to Apple Music" prompt (MusicKit authorization)
|
||||||
|
* - the Music User Token the server needs to read recently played
|
||||||
|
* - live now-playing from the Music app while our app is open
|
||||||
|
* (iOS doesn't let apps observe the Music app in the background)
|
||||||
|
*/
|
||||||
|
public class AppleMusicModule: Module {
|
||||||
|
private var observers: [NSObjectProtocol] = []
|
||||||
|
|
||||||
|
public func definition() -> ModuleDefinition {
|
||||||
|
Name("AppleMusic")
|
||||||
|
|
||||||
|
Events("onNowPlayingChange")
|
||||||
|
|
||||||
|
Function("authorizationStatus") { () -> String in
|
||||||
|
Self.describe(MusicAuthorization.currentStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("requestAuthorization") { () async -> String in
|
||||||
|
Self.describe(await MusicAuthorization.request())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// developerToken comes from our server (signed with the MusicKit key).
|
||||||
|
AsyncFunction("getUserToken") { (developerToken: String) async throws -> String in
|
||||||
|
try await MusicUserTokenProvider().userToken(for: developerToken, options: .ignoreCache)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("getNowPlaying") { () async -> [String: Any] in
|
||||||
|
await MainActor.run { Self.snapshot() }
|
||||||
|
}
|
||||||
|
|
||||||
|
OnStartObserving {
|
||||||
|
DispatchQueue.main.async { self.startObserving() }
|
||||||
|
}
|
||||||
|
|
||||||
|
OnStopObserving {
|
||||||
|
DispatchQueue.main.async { self.stopObserving() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startObserving() {
|
||||||
|
guard observers.isEmpty, MusicAuthorization.currentStatus == .authorized else { return }
|
||||||
|
let player = MPMusicPlayerController.systemMusicPlayer
|
||||||
|
player.beginGeneratingPlaybackNotifications()
|
||||||
|
let center = NotificationCenter.default
|
||||||
|
for name in [Notification.Name.MPMusicPlayerControllerNowPlayingItemDidChange,
|
||||||
|
Notification.Name.MPMusicPlayerControllerPlaybackStateDidChange] {
|
||||||
|
observers.append(center.addObserver(forName: name, object: player, queue: .main) { [weak self] _ in
|
||||||
|
self?.sendEvent("onNowPlayingChange", Self.snapshot())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopObserving() {
|
||||||
|
observers.forEach { NotificationCenter.default.removeObserver($0) }
|
||||||
|
observers.removeAll()
|
||||||
|
MPMusicPlayerController.systemMusicPlayer.endGeneratingPlaybackNotifications()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Must be called on the main thread.
|
||||||
|
private static func snapshot() -> [String: Any] {
|
||||||
|
let player = MPMusicPlayerController.systemMusicPlayer
|
||||||
|
guard let item = player.nowPlayingItem, let title = item.title, !title.isEmpty else {
|
||||||
|
return ["state": "stopped"]
|
||||||
|
}
|
||||||
|
let state: String
|
||||||
|
switch player.playbackState {
|
||||||
|
case .playing, .seekingForward, .seekingBackward: state = "playing"
|
||||||
|
case .paused, .interrupted: state = "paused"
|
||||||
|
default: state = "stopped"
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
"state": state,
|
||||||
|
"title": title,
|
||||||
|
"artist": item.artist ?? "",
|
||||||
|
"album": item.albumTitle ?? "",
|
||||||
|
"durationMs": item.playbackDuration * 1000,
|
||||||
|
"storeId": item.playbackStoreID,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func describe(_ status: MusicAuthorization.Status) -> String {
|
||||||
|
switch status {
|
||||||
|
case .authorized: return "authorized"
|
||||||
|
case .denied: return "denied"
|
||||||
|
case .restricted: return "restricted"
|
||||||
|
case .notDetermined: return "notDetermined"
|
||||||
|
@unknown default: return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
export type AppleMusicAuthorization = 'authorized' | 'denied' | 'restricted' | 'notDetermined' | 'unknown';
|
||||||
|
|
||||||
|
export type MusicAppPlayback =
|
||||||
|
| { state: 'stopped' }
|
||||||
|
| {
|
||||||
|
state: 'playing' | 'paused' | 'stopped';
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
album: string;
|
||||||
|
durationMs: number;
|
||||||
|
storeId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AppleMusicModuleEvents = {
|
||||||
|
onNowPlayingChange: (playback: MusicAppPlayback) => void;
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { NativeModule, requireOptionalNativeModule } from 'expo';
|
||||||
|
|
||||||
|
import type { AppleMusicAuthorization, AppleMusicModuleEvents, MusicAppPlayback } from './AppleMusic.types';
|
||||||
|
|
||||||
|
declare class AppleMusicModule extends NativeModule<AppleMusicModuleEvents> {
|
||||||
|
authorizationStatus(): AppleMusicAuthorization;
|
||||||
|
requestAuthorization(): Promise<AppleMusicAuthorization>;
|
||||||
|
getUserToken(developerToken: string): Promise<string>;
|
||||||
|
getNowPlaying(): Promise<MusicAppPlayback>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** iOS only. null on Android, web and Expo Go, so callers must feature-check. */
|
||||||
|
export default requireOptionalNativeModule<AppleMusicModule>('AppleMusic');
|
||||||
@@ -3,13 +3,15 @@ import { useEffect, useState } from 'react';
|
|||||||
import { FlatList, RefreshControl, StyleSheet, Text, View } from 'react-native';
|
import { FlatList, RefreshControl, StyleSheet, Text, View } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
import { sortFeed, type FeedItemDTO, type ProfileDTO } from '@musicshare/shared';
|
import { sortFeed, type ConnectionDTO, type FeedItemDTO, type ProfileDTO } from '@musicshare/shared';
|
||||||
import { NowPlayingCard } from '@/components/NowPlayingCard';
|
import { NowPlayingCard } from '@/components/NowPlayingCard';
|
||||||
import { SharingControls } from '@/components/SharingControls';
|
import { SharingControls } from '@/components/SharingControls';
|
||||||
import { Body, Button, Card, Title } from '@/components/ui';
|
import { Body, Button, Card, Title } from '@/components/ui';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { showError } from '@/lib/errors';
|
import { showError } from '@/lib/errors';
|
||||||
import { shouldOfferSetup, useReader } from '@/lib/nowPlayingReader';
|
import { appleMusicAvailable } from '@/lib/appleMusic';
|
||||||
|
import { useReader } from '@/lib/nowPlayingReader';
|
||||||
|
import { shouldOfferSetup } from '@/lib/setup';
|
||||||
import { useRealtime } from '@/lib/useRealtime';
|
import { useRealtime } from '@/lib/useRealtime';
|
||||||
import { useSession } from '@/lib/session';
|
import { useSession } from '@/lib/session';
|
||||||
import { colors, space } from '@/lib/theme';
|
import { colors, space } from '@/lib/theme';
|
||||||
@@ -22,6 +24,8 @@ export default function FeedScreen() {
|
|||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
const reader = useReader();
|
const reader = useReader();
|
||||||
|
const { data: connections } = useSWR<ConnectionDTO[]>(appleMusicAvailable ? '/connections' : null);
|
||||||
|
const offerAppleMusic = appleMusicAvailable && connections !== undefined && connections.length === 0;
|
||||||
|
|
||||||
// First run on Android: open the one-tap setup once (deferred so navigation has mounted).
|
// First run on Android: open the one-tap setup once (deferred so navigation has mounted).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -74,6 +78,12 @@ export default function FeedScreen() {
|
|||||||
ListHeaderComponent={
|
ListHeaderComponent={
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<SharingControls compact />
|
<SharingControls compact />
|
||||||
|
{offerAppleMusic && (
|
||||||
|
<Card>
|
||||||
|
<Body>Connect Apple Music so friends see what you play. One tap.</Body>
|
||||||
|
<Button small variant="primary" title="Connect" onPress={() => router.push('/share-setup')} style={{ alignSelf: 'flex-start' }} />
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
{reader.available && !reader.granted && (
|
{reader.available && !reader.granted && (
|
||||||
<Card>
|
<Card>
|
||||||
<Body>Share what you’re playing in Spotify, YouTube Music and other apps. One tap, no accounts.</Body>
|
<Body>Share what you’re playing in Spotify, YouTube Music and other apps. One tap, no accounts.</Body>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { SharingControls } from '@/components/SharingControls';
|
|||||||
import { Body, Button, Card, Chip, Field, Heading, Row, Title } from '@/components/ui';
|
import { Body, Button, Card, Chip, Field, Heading, Row, Title } from '@/components/ui';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { showError } from '@/lib/errors';
|
import { showError } from '@/lib/errors';
|
||||||
|
import { AppleMusicPermissionError, appleMusicAvailable, connectAppleMusic } from '@/lib/appleMusic';
|
||||||
import { openReaderSettings, useReader } from '@/lib/nowPlayingReader';
|
import { openReaderSettings, useReader } from '@/lib/nowPlayingReader';
|
||||||
import { useDebounced } from '@/lib/useDebounced';
|
import { useDebounced } from '@/lib/useDebounced';
|
||||||
import { useSession } from '@/lib/session';
|
import { useSession } from '@/lib/session';
|
||||||
@@ -127,18 +128,54 @@ function Connections() {
|
|||||||
await mutate();
|
await mutate();
|
||||||
};
|
};
|
||||||
|
|
||||||
const connectOAuth = (provider: 'spotify' | 'plex') =>
|
/** Sign in on the service's site and come back via deep link. Returns true when connected. */
|
||||||
|
const signIn = async (provider: 'spotify' | 'plex' | 'lastfm'): Promise<boolean> => {
|
||||||
|
// Deep link back into the app; in Expo Go this is an exp:// URL, in builds musicshare://
|
||||||
|
const returnTo = Linking.createURL('settings');
|
||||||
|
const { url } = await api.authorizeUrl(provider, { client: 'mobile', returnTo });
|
||||||
|
const result = await WebBrowser.openAuthSessionAsync(url, returnTo);
|
||||||
|
if (result.type !== 'success') return false;
|
||||||
|
const error = Linking.parse(result.url).queryParams?.error;
|
||||||
|
if (error === 'spotify_limited') {
|
||||||
|
offerSpotifyViaLastfm();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (typeof error === 'string') {
|
||||||
|
Alert.alert(`Couldn't connect ${SERVICE_LABELS[provider]}`, error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const connectOAuth = (provider: 'spotify' | 'plex' | 'lastfm') => run(() => signIn(provider));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spotify only lets small apps sign in a handful of accounts. Spotify's own
|
||||||
|
* Last.fm link works for everyone: sign in to Last.fm here, then approve
|
||||||
|
* "Spotify Scrobbling" on Last.fm.
|
||||||
|
*/
|
||||||
|
const linkSpotifyOnLastfm = () =>
|
||||||
|
WebBrowser.openBrowserAsync('https://www.last.fm/settings/applications').catch((e) => showError(e));
|
||||||
|
|
||||||
|
const setUpSpotifyViaLastfm = () =>
|
||||||
run(async () => {
|
run(async () => {
|
||||||
// Deep link back into the app; in Expo Go this is an exp:// URL, in builds musicshare://
|
if (!byProvider.has('lastfm') && !(await signIn('lastfm'))) return;
|
||||||
const returnTo = Linking.createURL('settings');
|
Alert.alert('Last step: link Spotify', 'On the next page, tap Connect next to "Spotify Scrobbling" and approve.', [
|
||||||
const { url } = await api.authorizeUrl(provider, { client: 'mobile', returnTo });
|
{ text: 'Open Last.fm', onPress: linkSpotifyOnLastfm },
|
||||||
const result = await WebBrowser.openAuthSessionAsync(url, returnTo);
|
]);
|
||||||
if (result.type === 'success') {
|
|
||||||
const error = Linking.parse(result.url).queryParams?.error;
|
|
||||||
if (typeof error === 'string') Alert.alert(`Couldn't connect ${SERVICE_LABELS[provider]}`, error);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function offerSpotifyViaLastfm() {
|
||||||
|
Alert.alert(
|
||||||
|
'Spotify sign-in is limited during beta',
|
||||||
|
"Spotify only allows a few accounts to sign in to new apps directly. You can still share Spotify through Last.fm: two quick approvals, and it works for everyone.",
|
||||||
|
[
|
||||||
|
{ text: 'Not now', style: 'cancel' },
|
||||||
|
{ text: 'Set up', onPress: setUpSpotifyViaLastfm },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const disconnect = (provider: NowPlayingProvider) =>
|
const disconnect = (provider: NowPlayingProvider) =>
|
||||||
Alert.alert(`Disconnect ${SERVICE_LABELS[provider]}?`, 'Friends will stop seeing what you play there.', [
|
Alert.alert(`Disconnect ${SERVICE_LABELS[provider]}?`, 'Friends will stop seeing what you play there.', [
|
||||||
{ text: 'Cancel', style: 'cancel' },
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
@@ -159,6 +196,16 @@ function Connections() {
|
|||||||
|
|
||||||
const isActive = (p: NowPlayingProvider) => byProvider.get(p)?.status === 'active';
|
const isActive = (p: NowPlayingProvider) => byProvider.get(p)?.status === 'active';
|
||||||
|
|
||||||
|
const linkAppleMusic = () =>
|
||||||
|
run(async () => {
|
||||||
|
try {
|
||||||
|
await connectAppleMusic();
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof AppleMusicPermissionError) Alert.alert('Apple Music access needed', err.message);
|
||||||
|
else throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<Heading>Music services</Heading>
|
<Heading>Music services</Heading>
|
||||||
@@ -166,6 +213,20 @@ function Connections() {
|
|||||||
Connect where you listen. Friends see what you're playing in real time.
|
Connect where you listen. Friends see what you're playing in real time.
|
||||||
</Body>
|
</Body>
|
||||||
|
|
||||||
|
{appleMusicAvailable && (
|
||||||
|
<View style={[styles.connectionColumn, styles.connection]}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={styles.provider}>Apple Music</Text>
|
||||||
|
{status('apple_music', 'One tap. Live while the app is open, recently played otherwise.')}
|
||||||
|
</View>
|
||||||
|
{isActive('apple_music') ? (
|
||||||
|
<Button small title="Disconnect" onPress={() => disconnect('apple_music')} />
|
||||||
|
) : (
|
||||||
|
<Button small variant="primary" title={byProvider.has('apple_music') ? 'Reconnect' : 'Connect'} onPress={linkAppleMusic} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{(['spotify', 'plex'] as const).map((p) => (
|
{(['spotify', 'plex'] as const).map((p) => (
|
||||||
<View key={p} style={[styles.connectionColumn, styles.connection]}>
|
<View key={p} style={[styles.connectionColumn, styles.connection]}>
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
@@ -180,16 +241,30 @@ function Connections() {
|
|||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{(['lastfm', 'listenbrainz'] as const).map((p) => (
|
<View style={styles.connectionColumn}>
|
||||||
<UsernameConnection
|
<View style={styles.connection}>
|
||||||
key={p}
|
<View style={{ flex: 1 }}>
|
||||||
provider={p}
|
<Text style={styles.provider}>Last.fm</Text>
|
||||||
status={status(p, p === 'lastfm' ? 'Anything that scrobbles: YouTube Music, Tidal, Deezer…' : 'Open-source scrobbling')}
|
{status('lastfm', 'Also the way to share Spotify for everyone (via Spotify’s Last.fm link).')}
|
||||||
active={byProvider.has(p)}
|
</View>
|
||||||
onConnect={(username) => run(() => api.connectUsername(p, username))}
|
{byProvider.has('lastfm') ? (
|
||||||
onDisconnect={() => disconnect(p)}
|
<Button small title="Disconnect" onPress={() => disconnect('lastfm')} />
|
||||||
/>
|
) : (
|
||||||
))}
|
<Button small variant="primary" title="Sign in" onPress={() => connectOAuth('lastfm')} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
{byProvider.has('lastfm') && (
|
||||||
|
<Button small title="Link Spotify to Last.fm" onPress={linkSpotifyOnLastfm} style={{ alignSelf: 'flex-start' }} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<UsernameConnection
|
||||||
|
provider="listenbrainz"
|
||||||
|
status={status('listenbrainz', 'Open-source scrobbling')}
|
||||||
|
active={byProvider.has('listenbrainz')}
|
||||||
|
onConnect={(username) => run(() => api.connectUsername('listenbrainz', username))}
|
||||||
|
onDisconnect={() => disconnect('listenbrainz')}
|
||||||
|
/>
|
||||||
|
|
||||||
<JellyfinConnection
|
<JellyfinConnection
|
||||||
status={status('jellyfin', 'Sign in to your Jellyfin server. Your password isn’t stored.')}
|
status={status('jellyfin', 'Sign in to your Jellyfin server. Your password isn’t stored.')}
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import * as SplashScreen from 'expo-splash-screen';
|
|||||||
import { StatusBar } from 'expo-status-bar';
|
import { StatusBar } from 'expo-status-bar';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { AppState, StyleSheet, View } from 'react-native';
|
import { AppState, StyleSheet, View } from 'react-native';
|
||||||
import { SWRConfig } from 'swr';
|
import useSWR, { SWRConfig } from 'swr';
|
||||||
|
import type { ConnectionDTO } from '@musicshare/shared';
|
||||||
import { Body, Button, Title } from '@/components/ui';
|
import { Body, Button, Title } from '@/components/ui';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
|
import { appleMusicAvailable, useAppleMusicLive } from '@/lib/appleMusic';
|
||||||
import { syncDevice } from '@/lib/nowPlayingReader';
|
import { syncDevice } from '@/lib/nowPlayingReader';
|
||||||
import { SessionProvider, useSession } from '@/lib/session';
|
import { SessionProvider, useSession } from '@/lib/session';
|
||||||
import { colors, space } from '@/lib/theme';
|
import { colors, space } from '@/lib/theme';
|
||||||
@@ -65,6 +67,7 @@ function AppNavigator() {
|
|||||||
return () => sub.remove();
|
return () => sub.remove();
|
||||||
},
|
},
|
||||||
}}>
|
}}>
|
||||||
|
{signedIn && appleMusicAvailable && <AppleMusicLive />}
|
||||||
<Stack
|
<Stack
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
headerStyle: { backgroundColor: colors.bg },
|
headerStyle: { backgroundColor: colors.bg },
|
||||||
@@ -86,6 +89,13 @@ function AppNavigator() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** iOS: mirror the Music app live while the app is open, once Apple Music is connected. */
|
||||||
|
function AppleMusicLive() {
|
||||||
|
const { data: connections } = useSWR<ConnectionDTO[]>('/connections');
|
||||||
|
useAppleMusicLive(Boolean(connections?.some((c) => c.provider === 'apple_music' && c.status === 'active')));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
offline: { flex: 1, backgroundColor: colors.bg, justifyContent: 'center', padding: space.xl, gap: space.md },
|
offline: { flex: 1, backgroundColor: colors.bg, justifyContent: 'center', padding: space.xl, gap: space.md },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,24 +1,83 @@
|
|||||||
import { router } from 'expo-router';
|
import { router } from 'expo-router';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { StyleSheet, View } from 'react-native';
|
import { Platform, StyleSheet, View } from 'react-native';
|
||||||
|
import { mutate } from 'swr';
|
||||||
import { Screen } from '@/components/Screen';
|
import { Screen } from '@/components/Screen';
|
||||||
import { Body, Button, Card, Title } from '@/components/ui';
|
import { Body, Button, Card, Title } from '@/components/ui';
|
||||||
import { showError } from '@/lib/errors';
|
import { showError } from '@/lib/errors';
|
||||||
import { markSetupSeen, openReaderSettings, syncDevice, useReader } from '@/lib/nowPlayingReader';
|
import { AppleMusicPermissionError, connectAppleMusic } from '@/lib/appleMusic';
|
||||||
|
import { openReaderSettings, syncDevice, useReader } from '@/lib/nowPlayingReader';
|
||||||
|
import { markSetupSeen } from '@/lib/setup';
|
||||||
import { colors, space } from '@/lib/theme';
|
import { colors, space } from '@/lib/theme';
|
||||||
|
|
||||||
/**
|
const close = () => (router.canGoBack() ? router.back() : router.replace('/'));
|
||||||
* One-time onboarding on Android. This doubles as the prominent disclosure Google
|
|
||||||
* Play requires before sending users to the notification-access screen.
|
|
||||||
*/
|
|
||||||
export default function ShareSetupScreen() {
|
|
||||||
const { granted, playback } = useReader();
|
|
||||||
const [synced, setSynced] = useState(false);
|
|
||||||
const finishing = granted && !synced;
|
|
||||||
|
|
||||||
|
export default function ShareSetupScreen() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
markSetupSeen();
|
markSetupSeen();
|
||||||
}, []);
|
}, []);
|
||||||
|
return Platform.OS === 'ios' ? <AppleMusicSetup /> : <AndroidSetup />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** iPhone: Apple Music is one system prompt. Other services live in Settings. */
|
||||||
|
function AppleMusicSetup() {
|
||||||
|
const [connecting, setConnecting] = useState(false);
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const connect = async () => {
|
||||||
|
setConnecting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await connectAppleMusic();
|
||||||
|
await mutate('/connections');
|
||||||
|
setConnected(true);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof AppleMusicPermissionError ? err.message : "Couldn't connect Apple Music. Try again.");
|
||||||
|
}
|
||||||
|
setConnecting(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen edges={['top', 'bottom']} contentContainerStyle={styles.content}>
|
||||||
|
<View style={{ gap: space.md }}>
|
||||||
|
<Title>{connected ? "You're all set" : 'Share what you’re playing'}</Title>
|
||||||
|
<Body muted>
|
||||||
|
{connected
|
||||||
|
? 'Play something in Apple Music and your friends will see it.'
|
||||||
|
: 'Connect Apple Music and your friends see what you play. It takes one tap.'}
|
||||||
|
</Body>
|
||||||
|
</View>
|
||||||
|
{!connected && (
|
||||||
|
<Card>
|
||||||
|
<Body style={styles.point}>• We see the songs you play in Apple Music, and nothing else.</Body>
|
||||||
|
<Body style={styles.point}>• Use Spotify or Plex? Connect them in Settings.</Body>
|
||||||
|
<Body style={styles.point}>• Pause sharing or disconnect any time.</Body>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
{error && <Body style={{ color: colors.danger }}>{error}</Body>}
|
||||||
|
<View style={{ gap: space.sm }}>
|
||||||
|
{connected ? (
|
||||||
|
<Button variant="primary" title="Done" onPress={close} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button variant="primary" title="Connect Apple Music" loading={connecting} onPress={connect} />
|
||||||
|
<Button title="Not now" onPress={close} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Android: one toggle covers every music app. This screen doubles as the
|
||||||
|
* prominent disclosure Google Play requires before the notification-access screen.
|
||||||
|
*/
|
||||||
|
function AndroidSetup() {
|
||||||
|
const { granted, playback } = useReader();
|
||||||
|
const [synced, setSynced] = useState(false);
|
||||||
|
const finishing = granted && !synced;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!granted) return;
|
if (!granted) return;
|
||||||
@@ -27,8 +86,6 @@ export default function ShareSetupScreen() {
|
|||||||
.finally(() => setSynced(true));
|
.finally(() => setSynced(true));
|
||||||
}, [granted]);
|
}, [granted]);
|
||||||
|
|
||||||
const close = () => (router.canGoBack() ? router.back() : router.replace('/'));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Screen edges={['top', 'bottom']} contentContainerStyle={styles.content}>
|
<Screen edges={['top', 'bottom']} contentContainerStyle={styles.content}>
|
||||||
<View style={{ gap: space.md }}>
|
<View style={{ gap: space.md }}>
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { AppState } from 'react-native';
|
||||||
|
import AppleMusic, { type MusicAppPlayback } from '@/modules/apple-music';
|
||||||
|
import { api } from './api';
|
||||||
|
import { reportPlayback } from './device';
|
||||||
|
|
||||||
|
/** True on iOS builds that include the native module (not Android, web, or Expo Go). */
|
||||||
|
export const appleMusicAvailable = AppleMusic !== null;
|
||||||
|
|
||||||
|
export class AppleMusicPermissionError extends Error {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The whole Apple Music setup: one system prompt. Asks for access, exchanges
|
||||||
|
* our developer token for the user's Music User Token, and hands it to the
|
||||||
|
* server, which then follows recently played in the background.
|
||||||
|
*/
|
||||||
|
export async function connectAppleMusic() {
|
||||||
|
if (!AppleMusic) throw new Error('Apple Music is only available in the iPhone app');
|
||||||
|
let status = AppleMusic.authorizationStatus();
|
||||||
|
if (status === 'notDetermined') status = await AppleMusic.requestAuthorization();
|
||||||
|
if (status !== 'authorized') {
|
||||||
|
throw new AppleMusicPermissionError(
|
||||||
|
status === 'restricted'
|
||||||
|
? 'Apple Music access is restricted on this iPhone (Screen Time or a device profile).'
|
||||||
|
: 'Turn on Media & Apple Music for musicshare in the Settings app, then try again.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { token } = await api.appleMusicDeveloperToken();
|
||||||
|
const musicUserToken = await AppleMusic.getUserToken(token);
|
||||||
|
return api.connectAppleMusic(musicUserToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
const HEARTBEAT_MS = 4 * 60_000; // the server expires silent phones after 9 minutes
|
||||||
|
let lastSent: { key: string; at: number } | null = null;
|
||||||
|
|
||||||
|
function report(playback: MusicAppPlayback) {
|
||||||
|
const key = 'title' in playback ? `${playback.state}|${playback.title}|${playback.artist}` : 'stopped';
|
||||||
|
const now = Date.now();
|
||||||
|
if (lastSent?.key === key && (playback.state !== 'playing' || now - lastSent.at < HEARTBEAT_MS)) return;
|
||||||
|
if (!lastSent && key === 'stopped') return; // nothing was shared yet; no need to say "stopped"
|
||||||
|
lastSent = { key, at: now };
|
||||||
|
reportPlayback(
|
||||||
|
'title' in playback && playback.state !== 'stopped'
|
||||||
|
? {
|
||||||
|
app: 'com.apple.Music',
|
||||||
|
appName: 'Apple Music',
|
||||||
|
state: playback.state,
|
||||||
|
track: {
|
||||||
|
title: playback.title,
|
||||||
|
artist: playback.artist,
|
||||||
|
album: playback.album || null,
|
||||||
|
durationMs: playback.durationMs > 0 ? Math.round(playback.durationMs) : null,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: { app: 'com.apple.Music', appName: 'Apple Music', state: 'stopped' },
|
||||||
|
).catch(() => {
|
||||||
|
lastSent = null; // retry on the next change
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* While the app is open, mirror the Music app exactly. iOS doesn't let us watch
|
||||||
|
* it in the background; then the server's recently-played polling takes over.
|
||||||
|
*/
|
||||||
|
export function useAppleMusicLive(enabled: boolean) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled || !AppleMusic || AppleMusic.authorizationStatus() !== 'authorized') return;
|
||||||
|
const music = AppleMusic;
|
||||||
|
const check = () => music.getNowPlaying().then(report).catch(() => {});
|
||||||
|
check();
|
||||||
|
const changes = music.addListener('onNowPlayingChange', report);
|
||||||
|
const foreground = AppState.addEventListener('change', (s) => s === 'active' && check());
|
||||||
|
const heartbeat = setInterval(check, HEARTBEAT_MS);
|
||||||
|
return () => {
|
||||||
|
changes.remove();
|
||||||
|
foreground.remove();
|
||||||
|
clearInterval(heartbeat);
|
||||||
|
};
|
||||||
|
}, [enabled]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import * as Device from 'expo-device';
|
||||||
|
import * as SecureStore from 'expo-secure-store';
|
||||||
|
import { Platform } from 'react-native';
|
||||||
|
import type { DevicePlaybackInput } from '@musicshare/shared';
|
||||||
|
import { api } from './api';
|
||||||
|
import { API_URL } from './config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This phone's device token: it can only report playback for the signed-in
|
||||||
|
* user (POST /devices/now-playing). Android's native listener and the iOS
|
||||||
|
* Music app observer both report through it.
|
||||||
|
*/
|
||||||
|
const DEVICE_KEY = 'musicshare.device';
|
||||||
|
|
||||||
|
type StoredDevice = { id: string; token: string };
|
||||||
|
|
||||||
|
async function storedDevice(): Promise<StoredDevice | null> {
|
||||||
|
const raw = await SecureStore.getItemAsync(DEVICE_KEY);
|
||||||
|
return raw ? (JSON.parse(raw) as StoredDevice) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let registering: Promise<StoredDevice> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registered device for this phone, registering on first use.
|
||||||
|
* Single-flight: several screens react to the same permission change, and
|
||||||
|
* concurrent calls would each register a device and race to store a token.
|
||||||
|
*/
|
||||||
|
export function ensureDevice({ replace = false } = {}): Promise<StoredDevice> {
|
||||||
|
registering ??= (async () => {
|
||||||
|
const existing = replace ? null : await storedDevice();
|
||||||
|
if (existing) return existing;
|
||||||
|
const res = await api.registerDevice({
|
||||||
|
platform: Platform.OS === 'ios' ? 'ios' : 'android',
|
||||||
|
name: Device.modelName ?? (Platform.OS === 'ios' ? 'iPhone' : 'Android phone'),
|
||||||
|
});
|
||||||
|
const device = { id: res.device.id, token: res.token };
|
||||||
|
await SecureStore.setItemAsync(DEVICE_KEY, JSON.stringify(device));
|
||||||
|
return device;
|
||||||
|
})().finally(() => {
|
||||||
|
registering = null;
|
||||||
|
});
|
||||||
|
return registering;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sign-out: revoke this phone's token while the session is still valid. */
|
||||||
|
export async function unregisterDevice(): Promise<void> {
|
||||||
|
const device = await storedDevice();
|
||||||
|
if (device) await api.removeDevice(device.id).catch(() => {});
|
||||||
|
await SecureStore.deleteItemAsync(DEVICE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Report playback from JS (iOS). Android reports natively so it works with the app closed. */
|
||||||
|
export async function reportPlayback(input: DevicePlaybackInput): Promise<void> {
|
||||||
|
const send = async (token: string) =>
|
||||||
|
fetch(`${API_URL}/devices/now-playing`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
let res = await send((await ensureDevice()).token);
|
||||||
|
// The server revoked this device (e.g. removed from another session): register again once.
|
||||||
|
if (res.status === 401) res = await send((await ensureDevice({ replace: true })).token);
|
||||||
|
if (!res.ok && res.status !== 401) throw new Error(`report failed (${res.status})`);
|
||||||
|
}
|
||||||
@@ -1,14 +1,10 @@
|
|||||||
import * as Device from 'expo-device';
|
|
||||||
import * as SecureStore from 'expo-secure-store';
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { AppState } from 'react-native';
|
import { AppState } from 'react-native';
|
||||||
import { ANDROID_MUSIC_APPS } from '@musicshare/shared';
|
import { ANDROID_MUSIC_APPS } from '@musicshare/shared';
|
||||||
import NowPlayingReader, { type NativePlayback } from '@/modules/now-playing-reader';
|
import NowPlayingReader, { type NativePlayback } from '@/modules/now-playing-reader';
|
||||||
import { api } from './api';
|
|
||||||
import { API_URL } from './config';
|
import { API_URL } from './config';
|
||||||
|
import { ensureDevice } from './device';
|
||||||
|
|
||||||
const DEVICE_KEY = 'musicshare.device';
|
|
||||||
const SETUP_SEEN_KEY = 'musicshare.shareSetupSeen';
|
|
||||||
|
|
||||||
/** True on Android builds that include the native module (not iOS, web, or Expo Go). */
|
/** True on Android builds that include the native module (not iOS, web, or Expo Go). */
|
||||||
export const readerAvailable = NowPlayingReader !== null;
|
export const readerAvailable = NowPlayingReader !== null;
|
||||||
@@ -20,57 +16,25 @@ const extraApps = String(process.env.EXPO_PUBLIC_EXTRA_MEDIA_APPS ?? '')
|
|||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
const allowedApps = [...Object.keys(ANDROID_MUSIC_APPS), ...extraApps];
|
const allowedApps = [...Object.keys(ANDROID_MUSIC_APPS), ...extraApps];
|
||||||
|
|
||||||
type StoredDevice = { id: string; token: string };
|
|
||||||
|
|
||||||
async function storedDevice(): Promise<StoredDevice | null> {
|
|
||||||
const raw = await SecureStore.getItemAsync(DEVICE_KEY);
|
|
||||||
return raw ? (JSON.parse(raw) as StoredDevice) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let syncing: Promise<void> | null = null;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Make sure this phone is registered and the native listener knows where to
|
* Make sure this phone is registered and the native listener knows where to
|
||||||
* report. Cheap to call repeatedly (app start, returning from settings).
|
* report. Cheap to call repeatedly (app start, returning from settings).
|
||||||
* Single-flight: several screens react to the same permission change, and
|
|
||||||
* concurrent calls would each register a device and race to store a token.
|
|
||||||
*/
|
*/
|
||||||
export function syncDevice(): Promise<void> {
|
export async function syncDevice(): Promise<void> {
|
||||||
syncing ??= doSyncDevice().finally(() => {
|
|
||||||
syncing = null;
|
|
||||||
});
|
|
||||||
return syncing;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function doSyncDevice(): Promise<void> {
|
|
||||||
if (!NowPlayingReader || !NowPlayingReader.isPermissionGranted()) return;
|
if (!NowPlayingReader || !NowPlayingReader.isPermissionGranted()) return;
|
||||||
let device = await storedDevice();
|
const device = await ensureDevice({ replace: NowPlayingReader.needsReregistration() });
|
||||||
if (!device || NowPlayingReader.needsReregistration()) {
|
|
||||||
const res = await api.registerDevice({ platform: 'android', name: Device.modelName ?? 'Android phone' });
|
|
||||||
device = { id: res.device.id, token: res.token };
|
|
||||||
await SecureStore.setItemAsync(DEVICE_KEY, JSON.stringify(device));
|
|
||||||
}
|
|
||||||
NowPlayingReader.configure(API_URL, device.token, allowedApps);
|
NowPlayingReader.configure(API_URL, device.token, allowedApps);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sign-out: stop reporting and revoke this phone's token (while the session is still valid). */
|
/** Sign-out: stop the native listener reporting. */
|
||||||
export async function unregisterDevice(): Promise<void> {
|
export const stopReader = () => NowPlayingReader?.clear();
|
||||||
NowPlayingReader?.clear();
|
|
||||||
const device = await storedDevice();
|
|
||||||
if (device) await api.removeDevice(device.id).catch(() => {});
|
|
||||||
await SecureStore.deleteItemAsync(DEVICE_KEY);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const openReaderSettings = () => NowPlayingReader?.openPermissionSettings();
|
export const openReaderSettings = () => NowPlayingReader?.openPermissionSettings();
|
||||||
|
|
||||||
/** After resuming sharing, re-send what's playing so friends see it immediately. */
|
/** After resuming sharing, re-send what's playing so friends see it immediately. */
|
||||||
export const resendCurrentPlayback = () => NowPlayingReader?.resend();
|
export const resendCurrentPlayback = () => NowPlayingReader?.resend();
|
||||||
|
|
||||||
export async function shouldOfferSetup(): Promise<boolean> {
|
export const readerGranted = () => NowPlayingReader?.isPermissionGranted() ?? false;
|
||||||
if (!NowPlayingReader || NowPlayingReader.isPermissionGranted()) return false;
|
|
||||||
return (await SecureStore.getItemAsync(SETUP_SEEN_KEY)) === null;
|
|
||||||
}
|
|
||||||
export const markSetupSeen = () => SecureStore.setItemAsync(SETUP_SEEN_KEY, '1');
|
|
||||||
|
|
||||||
/** Live permission state and what the listener currently sees. Re-checks when the app returns to the foreground. */
|
/** Live permission state and what the listener currently sees. Re-checks when the app returns to the foreground. */
|
||||||
export function useReader() {
|
export function useReader() {
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import * as SecureStore from 'expo-secure-store';
|
|||||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||||
import { ApiError, type AuthResponse, type MeDTO } from '@musicshare/shared';
|
import { ApiError, type AuthResponse, type MeDTO } from '@musicshare/shared';
|
||||||
import { api, setApiToken } from './api';
|
import { api, setApiToken } from './api';
|
||||||
import { unregisterDevice } from './nowPlayingReader';
|
import { unregisterDevice } from './device';
|
||||||
|
import { stopReader } from './nowPlayingReader';
|
||||||
|
|
||||||
const TOKEN_KEY = 'musicshare.session';
|
const TOKEN_KEY = 'musicshare.session';
|
||||||
|
|
||||||
@@ -60,6 +61,7 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const signOut = useCallback(async () => {
|
const signOut = useCallback(async () => {
|
||||||
|
stopReader();
|
||||||
await unregisterDevice().catch(() => {});
|
await unregisterDevice().catch(() => {});
|
||||||
await api.logout().catch(() => {});
|
await api.logout().catch(() => {});
|
||||||
await SecureStore.deleteItemAsync(TOKEN_KEY);
|
await SecureStore.deleteItemAsync(TOKEN_KEY);
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import * as SecureStore from 'expo-secure-store';
|
||||||
|
import { Platform } from 'react-native';
|
||||||
|
import { api } from './api';
|
||||||
|
import { appleMusicAvailable } from './appleMusic';
|
||||||
|
import { readerAvailable, readerGranted } from './nowPlayingReader';
|
||||||
|
|
||||||
|
const SETUP_SEEN_KEY = 'musicshare.shareSetupSeen';
|
||||||
|
|
||||||
|
/** First run: should we open the one-tap "share what you're playing" screen? */
|
||||||
|
export async function shouldOfferSetup(): Promise<boolean> {
|
||||||
|
if ((await SecureStore.getItemAsync(SETUP_SEEN_KEY)) !== null) return false;
|
||||||
|
if (Platform.OS === 'android') return readerAvailable && !readerGranted();
|
||||||
|
if (Platform.OS === 'ios' && appleMusicAvailable) {
|
||||||
|
const connections = await api.connections().catch(() => []);
|
||||||
|
return connections.length === 0;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const markSetupSeen = () => SecureStore.setItemAsync(SETUP_SEEN_KEY, '1');
|
||||||
Reference in New Issue
Block a user