feat: naive scrobbling integration
This commit is contained in:
@@ -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 (
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
@@ -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'
|
||||||
44
src/screens/Settings/components/PlaybackReporting.tsx
Normal file
44
src/screens/Settings/components/PlaybackReporting.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
@@ -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;
|
||||||
@@ -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(() => {});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user