feat: naive scrobbling integration

This commit is contained in:
Lei Nelissen
2023-04-27 15:08:10 +02:00
parent fb4d3932e5
commit 0bf2775c93
10 changed files with 176 additions and 9 deletions

View File

@@ -30,9 +30,12 @@ const DarkTheme = {
} }
}; };
// Track whether the player has already been setup, so that we don't
// accidentally do it twice.
let hasSetupPlayer = false;
export default function App(): JSX.Element { export default function App(): JSX.Element {
const colorScheme = useColorScheme(); const colorScheme = useColorScheme();
// const colorScheme = 'dark';
const theme = themes[colorScheme || 'light']; const theme = themes[colorScheme || 'light'];
useEffect(() => { useEffect(() => {
@@ -47,9 +50,14 @@ export default function App(): JSX.Element {
Capability.Stop, Capability.Stop,
Capability.SeekTo, Capability.SeekTo,
], ],
progressUpdateEventInterval: 5,
}); });
} }
setupTrackPlayer();
if (!hasSetupPlayer) {
setupTrackPlayer();
hasSetupPlayer = true;
}
}, []); }, []);
return ( return (

View File

@@ -61,5 +61,7 @@
"local-playback": "Local playback", "local-playback": "Local playback",
"streaming": "Streaming", "streaming": "Streaming",
"total-duration": "Total duration", "total-duration": "Total duration",
"similar-albums": "Similar albums" "similar-albums": "Similar albums",
"playback-reporting": "Playback Reporting",
"playback-reporting-description": "With Playback Reporting, all your playback events are relayed back to Jellyfin. This allows you to track your most listened songs, particularly with Jellyfin plugins such as ListenBrainz."
} }

View File

@@ -59,4 +59,6 @@ export type LocaleKeys = 'play-next'
| 'local-playback' | 'local-playback'
| 'streaming' | 'streaming'
| 'total-duration' | 'total-duration'
| 'similar-albums' | 'similar-albums'
| 'playback-reporting'
| 'playback-reporting-description'

View File

@@ -0,0 +1,44 @@
import { Paragraph, Text } from 'components/Typography';
import React, { useCallback } from 'react';
import { Switch } from 'react-native-gesture-handler';
import styled from 'styled-components/native';
import { t } from '@localisation';
import { SafeScrollView } from 'components/SafeNavigatorView';
import { useAppDispatch, useTypedSelector } from 'store';
import { setEnablePlaybackReporting } from 'store/settings/actions';
const Container = styled.View`
padding: 24px;
`;
const SwitchContainer = styled.View`
flex-direction: row;
justify-content: space-between;
align-items: center;
margin: 16px 0;
`;
const Label = styled(Text)`
font-size: 16px;
`;
export default function PlaybackReporting() {
const isEnabled = useTypedSelector((state) => state.settings.enablePlaybackReporting);
const dispatch = useAppDispatch();
const toggleSwitch = useCallback(() => {
dispatch(setEnablePlaybackReporting(!isEnabled));
}, [isEnabled, dispatch]);
return (
<SafeScrollView>
<Container>
<Paragraph>{t('playback-reporting-description')}</Paragraph>
<SwitchContainer>
<Label>{t('playback-reporting')}</Label>
<Switch value={isEnabled} onValueChange={toggleSwitch} />
</SwitchContainer>
</Container>
</SafeScrollView>
);
}

View File

@@ -11,18 +11,21 @@ import { THEME_COLOR } from 'CONSTANTS';
import Sentry from './components/Sentry'; import Sentry from './components/Sentry';
import { SettingsNavigationProp } from './types'; import { SettingsNavigationProp } from './types';
import { SafeScrollView } from 'components/SafeNavigatorView'; import { SafeScrollView } from 'components/SafeNavigatorView';
import PlaybackReporting from './components/PlaybackReporting';
export function SettingsList() { export function SettingsList() {
const navigation = useNavigation<SettingsNavigationProp>(); const navigation = useNavigation<SettingsNavigationProp>();
const handleLibraryClick = useCallback(() => { navigation.navigate('Library'); }, [navigation]); const handleLibraryClick = useCallback(() => { navigation.navigate('Library'); }, [navigation]);
const handleCacheClick = useCallback(() => { navigation.navigate('Cache'); }, [navigation]); const handleCacheClick = useCallback(() => { navigation.navigate('Cache'); }, [navigation]);
const handleSentryClick = useCallback(() => { navigation.navigate('Sentry'); }, [navigation]); const handleSentryClick = useCallback(() => { navigation.navigate('Sentry'); }, [navigation]);
const handlePlaybackReportingClick = useCallback(() => { navigation.navigate('Playback Reporting'); }, [navigation]);
return ( return (
<SafeScrollView> <SafeScrollView>
<ListButton onPress={handleLibraryClick}>{t('jellyfin-library')}</ListButton> <ListButton onPress={handleLibraryClick}>{t('jellyfin-library')}</ListButton>
<ListButton onPress={handleCacheClick}>{t('setting-cache')}</ListButton> <ListButton onPress={handleCacheClick}>{t('setting-cache')}</ListButton>
<ListButton onPress={handleSentryClick}>{t('error-reporting')}</ListButton> <ListButton onPress={handleSentryClick}>{t('error-reporting')}</ListButton>
<ListButton onPress={handlePlaybackReportingClick}>{t('playback-reporting')}</ListButton>
</SafeScrollView> </SafeScrollView>
); );
} }
@@ -43,6 +46,7 @@ export default function Settings() {
<Stack.Screen name="Library" component={Library} options={{ headerTitle: t('jellyfin-library') }} /> <Stack.Screen name="Library" component={Library} options={{ headerTitle: t('jellyfin-library') }} />
<Stack.Screen name="Cache" component={Cache} options={{ headerTitle: t('setting-cache') }} /> <Stack.Screen name="Cache" component={Cache} options={{ headerTitle: t('setting-cache') }} />
<Stack.Screen name="Sentry" component={Sentry} options={{ headerTitle: t('error-reporting') }} /> <Stack.Screen name="Sentry" component={Sentry} options={{ headerTitle: t('error-reporting') }} />
<Stack.Screen name="Playback Reporting" component={PlaybackReporting} options={{ headerTitle: t('playback-reporting')}} />
</Stack.Navigator> </Stack.Navigator>
); );
} }

View File

@@ -33,7 +33,17 @@ const persistConfig: PersistConfig<Omit<AppState, '_persist'>> = {
queued: [] queued: []
} }
}; };
} },
// @ts-expect-error migrations are poorly typed
3: (state: AppState) => {
return {
...state,
settings: {
...state.settings,
enablePlaybackReporting: true,
}
};
},
}) })
}; };

View File

@@ -3,4 +3,5 @@ import { createAction } from '@reduxjs/toolkit';
export const setJellyfinCredentials = createAction<{ access_token: string, user_id: string, uri: string, device_id: string; }>('SET_JELLYFIN_CREDENTIALS'); export const setJellyfinCredentials = createAction<{ access_token: string, user_id: string, uri: string, device_id: string; }>('SET_JELLYFIN_CREDENTIALS');
export const setBitrate = createAction<number>('SET_BITRATE'); export const setBitrate = createAction<number>('SET_BITRATE');
export const setOnboardingStatus = createAction<boolean>('SET_ONBOARDING_STATUS'); export const setOnboardingStatus = createAction<boolean>('SET_ONBOARDING_STATUS');
export const setReceivedErrorReportingAlert = createAction<void>('SET_RECEIVED_ERROR_REPORTING_ALERT'); export const setReceivedErrorReportingAlert = createAction<void>('SET_RECEIVED_ERROR_REPORTING_ALERT');
export const setEnablePlaybackReporting = createAction<boolean>('SET_ENABLE_PLAYBACK_REPORTING');

View File

@@ -1,5 +1,5 @@
import { createReducer } from '@reduxjs/toolkit'; import { createReducer } from '@reduxjs/toolkit';
import { setReceivedErrorReportingAlert, setBitrate, setJellyfinCredentials, setOnboardingStatus } from './actions'; import { setReceivedErrorReportingAlert, setBitrate, setJellyfinCredentials, setOnboardingStatus, setEnablePlaybackReporting } from './actions';
interface State { interface State {
jellyfin?: { jellyfin?: {
@@ -11,12 +11,14 @@ interface State {
bitrate: number; bitrate: number;
isOnboardingComplete: boolean; isOnboardingComplete: boolean;
hasReceivedErrorReportingAlert: boolean; hasReceivedErrorReportingAlert: boolean;
enablePlaybackReporting: boolean;
} }
const initialState: State = { const initialState: State = {
bitrate: 140000000, bitrate: 140000000,
isOnboardingComplete: false, isOnboardingComplete: false,
hasReceivedErrorReportingAlert: false, hasReceivedErrorReportingAlert: false,
enablePlaybackReporting: true,
}; };
const settings = createReducer(initialState, builder => { const settings = createReducer(initialState, builder => {
@@ -36,6 +38,10 @@ const settings = createReducer(initialState, builder => {
...state, ...state,
hasReceivedErrorReportingAlert: true, hasReceivedErrorReportingAlert: true,
})); }));
builder.addCase(setEnablePlaybackReporting, (state, action) => ({
...state,
enablePlaybackReporting: action.payload,
}));
}); });
export default settings; export default settings;

View File

@@ -1,4 +1,4 @@
import { Track } from 'react-native-track-player'; import TrackPlayer, { RepeatMode, State, Track } from 'react-native-track-player';
import { AppState, useTypedSelector } from 'store'; import { AppState, useTypedSelector } from 'store';
import { Album, AlbumTrack, SimilarAlbum } from 'store/music/types'; import { Album, AlbumTrack, SimilarAlbum } from 'store/music/types';
@@ -241,3 +241,58 @@ export async function retrievePlaylistTracks(ItemId: string, credentials: Creden
return playlists.Items; return playlists.Items;
} }
/**
* This maps the react-native-track-player RepeatMode to a RepeatMode that is
* expected by Jellyfin when reporting playback events.
*/
const RepeatModeMap: Record<RepeatMode, string> = {
[RepeatMode.Off]: 'RepeatNone',
[RepeatMode.Track]: 'RepeatOne',
[RepeatMode.Queue]: 'RepeatAll',
};
/**
* This will generate the payload that is required for playback events and send
* it to the supplied path.
*/
export async function sendPlaybackEvent(path: string, credentials: Credentials) {
// Extract all data from react-native-track-player
const [
track, position, repeatMode, volume, queue, state,
] = await Promise.all([
TrackPlayer.getCurrentTrack(),
TrackPlayer.getPosition(),
TrackPlayer.getRepeatMode(),
TrackPlayer.getVolume(),
TrackPlayer.getQueue(),
TrackPlayer.getState(),
]);
// Generate a payload from the gathered data
const payload = {
VolumeLevel: volume * 100,
IsMuted: false,
IsPaused: state === State.Paused,
RepeatMode: RepeatModeMap[repeatMode],
ShuffleMode: 'Sorted',
PositionTicks: position * 1_000_000,
PlaybackRate: 1,
PlayMethod: 'transcode',
MediaSourceId: track ? queue[track].backendId : null,
ItemId: track ? queue[track].backendId : null,
CanSeek: true,
PlaybackStartTimeTicks: null,
};
// Generate a config from the credentials and dispatch the request
const config = generateConfig(credentials);
await fetch(`${credentials?.uri}${path}`, {
headers: {
...config.headers,
'Content-Type': 'application/json'
},
body: JSON.stringify(payload),
// Swallow and errors from the request
}).catch(() => {});
}

View File

@@ -7,7 +7,9 @@
* such as processing media buttons or analytics * such as processing media buttons or analytics
*/ */
import TrackPlayer, { Event } from 'react-native-track-player'; import TrackPlayer, { Event, State } from 'react-native-track-player';
import store from 'store';
import { sendPlaybackEvent } from './JellyfinApi';
export default async function() { export default async function() {
TrackPlayer.addEventListener(Event.RemotePlay, () => { TrackPlayer.addEventListener(Event.RemotePlay, () => {
@@ -33,5 +35,38 @@ export default async function() {
TrackPlayer.addEventListener(Event.RemoteSeek, (event) => { TrackPlayer.addEventListener(Event.RemoteSeek, (event) => {
TrackPlayer.seekTo(event.position); TrackPlayer.seekTo(event.position);
}); });
TrackPlayer.addEventListener(Event.PlaybackTrackChanged, () => {
// Retrieve the current settings from the Redux store
const settings = store.getState().settings;
// GUARD: Only report playback when the settings is enabled
if (settings.enablePlaybackReporting) {
sendPlaybackEvent('/Sessions/Playing', settings.jellyfin);
}
});
TrackPlayer.addEventListener(Event.PlaybackProgressUpdated, () => {
// Retrieve the current settings from the Redux store
const settings = store.getState().settings;
// GUARD: Only report playback when the settings is enabled
if (settings.enablePlaybackReporting) {
sendPlaybackEvent('/Sessions/Playing/Progress', settings.jellyfin);
}
});
TrackPlayer.addEventListener(Event.PlaybackState, (event) => {
// GUARD: Only respond to paused and stopped events
if (event.state === State.Paused || event.state === State.Stopped) {
// Retrieve the current settings from the Redux store
const settings = store.getState().settings;
// GUARD: Only report playback when the settings is enabled
if (settings.enablePlaybackReporting) {
sendPlaybackEvent('/Sessions/Playing/Stopped', settings.jellyfin);
}
}
});
} }