Localization support (#99)

* basic i18n poc

* translate home, filters, tabs

support dot notation in backend for namespaces

* i18n context menu, artist filters, list controls

also nothings here
fix backend not caching fallback

* i18n queue, artist view, search/results

* i18n settings and server view

* Added translation using Weblate (Norwegian Bokmål)

* Translated using Weblate (Norwegian Bokmål)

Currently translated at 100.0% (6 of 6 strings)

Translation: Subtracks/subtracks
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/nb_NO/

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Translation: Subtracks/subtracks
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Translation: Subtracks/subtracks
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Translation: Subtracks/subtracks
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/

* fix url escaping

* added some mostly naive text overflow fixes

rewrote filter context menu as a slide in because the old one apparently can't handle dynamic width

* Added translation using Weblate (French)

* Translated using Weblate (French)

Currently translated at 17.4% (11 of 63 strings)

Translation: Subtracks/subtracks
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/fr/

* Translated using Weblate (French)

Currently translated at 19.0% (12 of 63 strings)

Translation: Subtracks/subtracks
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/fr/

* Translated using Weblate (French)

Currently translated at 40.0% (26 of 65 strings)

Translation: Subtracks/subtracks
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/fr/

* add weblate and some pretty badges to readme

* fix link

* Translated using Weblate (French)

Currently translated at 50.7% (33 of 65 strings)

Translation: Subtracks/subtracks
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/fr/

* Translated using Weblate (English)

Currently translated at 100.0% (65 of 65 strings)

Translation: Subtracks/subtracks
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/en/

* Translated using Weblate (French)

Currently translated at 90.7% (59 of 65 strings)

Translation: Subtracks/subtracks
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/fr/

* i18n now playing context type

fix overscroll on new filter menu
fix getting default namespace from the i18n backend

* Translated using Weblate (French)

Currently translated at 96.9% (63 of 65 strings)

Translation: Subtracks/subtracks
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (66 of 66 strings)

Translation: Subtracks/subtracks
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/fr/

* Translated using Weblate (Japanese) (#98)

Currently translated at 7.5% (5 of 66 strings)

Translation: Subtracks/subtracks
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/ja/

Co-authored-by: Austin Riedhammer <austinried@functionkey.xyz>

* little note to remind me why that's there

* update licenses

Co-authored-by: Allan Nordhøy <epost@anotheragency.no>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Clyhtsuriva <aimeric@adjutor.xyz>
This commit is contained in:
austinried
2022-04-15 12:11:00 +09:00
committed by GitHub
parent 4905f75564
commit 860a4cec16
35 changed files with 1186 additions and 316 deletions

View File

@@ -5,6 +5,7 @@ import GradientScrollView from '@app/components/GradientScrollView'
import Header from '@app/components/Header'
import HeaderBar from '@app/components/HeaderBar'
import ListItem from '@app/components/ListItem'
import { withSuspenseMemo } from '@app/components/withSuspense'
import { useQueryArtist, useQueryArtistTopSongs } from '@app/hooks/query'
import { useSetQueue } from '@app/hooks/trackplayer'
import { Album, Song } from '@app/models/library'
@@ -15,6 +16,7 @@ import { useLayout } from '@react-native-community/hooks'
import { useNavigation } from '@react-navigation/native'
import equal from 'fast-deep-equal/es6/react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'
import { useAnimatedScrollHandler, useAnimatedStyle, useSharedValue } from 'react-native-reanimated'
@@ -42,53 +44,63 @@ const AlbumItem = React.memo<{
)
}, equal)
const TopSongs = React.memo<{
const TopSongs = withSuspenseMemo<{
songs: Song[]
name: string
}>(({ songs, name }) => {
const { setQueue, isReady, contextId } = useSetQueue('artist', songs)
}>(
({ songs, name }) => {
const { setQueue, isReady, contextId } = useSetQueue('artist', songs)
const { t } = useTranslation('resources.song.lists')
return (
<>
<Header>Top Songs</Header>
{songs.slice(0, 5).map((s, i) => (
<ListItem
key={i}
item={s}
contextId={contextId}
queueId={i}
showArt={true}
subtitle={s.album}
onPress={() => setQueue({ title: name, playTrack: i })}
disabled={!isReady}
/>
))}
</>
)
}, equal)
const ArtistAlbums = React.memo<{
albums: Album[]
}>(({ albums }) => {
const albumsLayout = useLayout()
const sortedAlbums = [...albums]
.sort((a, b) => a.name.localeCompare(b.name))
.sort((a, b) => (b.year || 0) - (a.year || 0))
const albumSize = albumsLayout.width / 2 - styles.contentContainer.paddingHorizontal / 2
return (
<>
<Header>Albums</Header>
<View style={styles.albums} onLayout={albumsLayout.onLayout}>
{sortedAlbums.map(a => (
<AlbumItem key={a.id} album={a} height={albumSize} width={albumSize} />
return (
<>
<Header>{t('artistTopSongs')}</Header>
{songs.slice(0, 5).map((s, i) => (
<ListItem
key={i}
item={s}
contextId={contextId}
queueId={i}
showArt={true}
subtitle={s.album}
onPress={() => setQueue({ title: name, playTrack: i })}
disabled={!isReady}
/>
))}
</View>
</>
)
}, equal)
</>
)
},
null,
equal,
)
const ArtistAlbums = withSuspenseMemo<{
albums: Album[]
}>(
({ albums }) => {
const albumsLayout = useLayout()
const { t } = useTranslation('resources.album')
const sortedAlbums = [...albums]
.sort((a, b) => a.name.localeCompare(b.name))
.sort((a, b) => (b.year || 0) - (a.year || 0))
const albumSize = albumsLayout.width / 2 - styles.contentContainer.paddingHorizontal / 2
return (
<>
<Header>{t('name', { count: 1 })}</Header>
<View style={styles.albums} onLayout={albumsLayout.onLayout}>
{sortedAlbums.map(a => (
<AlbumItem key={a.id} album={a} height={albumSize} width={albumSize} />
))}
</View>
</>
)
},
null,
equal,
)
const ArtistViewFallback = React.memo(() => (
<GradientBackground style={styles.fallback}>

View File

@@ -3,25 +3,20 @@ import CoverArt from '@app/components/CoverArt'
import GradientScrollView from '@app/components/GradientScrollView'
import Header from '@app/components/Header'
import NothingHere from '@app/components/NothingHere'
import { withSuspenseMemo } from '@app/components/withSuspense'
import { useQueryHomeLists } from '@app/hooks/query'
import { Album } from '@app/models/library'
import { useStoreDeep } from '@app/state/store'
import colors from '@app/styles/colors'
import font from '@app/styles/font'
import { GetAlbumList2TypeBase, GetAlbumListType } from '@app/subsonic/params'
import { GetAlbumList2TypeBase } from '@app/subsonic/params'
import { useNavigation } from '@react-navigation/native'
import equal from 'fast-deep-equal/es6/react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
const titles: { [key in GetAlbumListType]?: string } = {
recent: 'Recently Played',
random: 'Random Albums',
frequent: 'Frequently Played',
starred: 'Starred Albums',
}
const AlbumItem = React.memo<{
album: Album
}>(({ album }) => {
@@ -49,6 +44,12 @@ const AlbumItem = React.memo<{
)
}, equal)
const CategoryHeader = withSuspenseMemo<{ type: string }>(({ type }) => {
const { t } = useTranslation('resources.album.lists')
console.log('type', type, t(type))
return <Header style={styles.header}>{t(type)}</Header>
})
const Category = React.memo<{
type: string
albums: Album[]
@@ -74,7 +75,7 @@ const Category = React.memo<{
return (
<View style={styles.category}>
<Header style={styles.header}>{titles[type as GetAlbumListType] || ''}</Header>
<CategoryHeader type={type} />
{albums.length > 0 ? <Albums /> : <Nothing />}
</View>
)

View File

@@ -1,16 +1,18 @@
import { AlbumContextPressable } from '@app/components/ContextMenu'
import CoverArt from '@app/components/CoverArt'
import FilterButton, { OptionData } from '@app/components/FilterButton'
import FilterButton from '@app/components/FilterButton'
import GradientFlatList from '@app/components/GradientFlatList'
import { withSuspenseMemo } from '@app/components/withSuspense'
import { useQueryAlbumList } from '@app/hooks/query'
import { Album } from '@app/models/library'
import { useStore, useStoreDeep } from '@app/state/store'
import colors from '@app/styles/colors'
import font from '@app/styles/font'
import { GetAlbumList2Type, GetAlbumList2TypeBase } from '@app/subsonic/params'
import { GetAlbumList2TypeBase } from '@app/subsonic/params'
import { useNavigation } from '@react-navigation/native'
import equal from 'fast-deep-equal/es6/react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { StyleSheet, Text, useWindowDimensions, View } from 'react-native'
const AlbumItem = React.memo<{
@@ -53,23 +55,36 @@ const AlbumListRenderItem: React.FC<{
item: { album: Album; size: number; height: number }
}> = ({ item }) => <AlbumItem album={item.album} size={item.size} height={item.height} />
const filterOptions: OptionData[] = [
{ text: 'By Name', value: 'alphabeticalByName' },
{ text: 'By Artist', value: 'alphabeticalByArtist' },
{ text: 'Newest', value: 'newest' },
{ text: 'Frequent', value: 'frequent' },
{ text: 'Recent', value: 'recent' },
{ text: 'Starred', value: 'starred' },
{ text: 'Random', value: 'random' },
// { text: 'By Year...', value: 'byYear' },
// { text: 'By Genre...', value: 'byGenre' },
const filterValues: GetAlbumList2TypeBase[] = [
'alphabeticalByName', //
'alphabeticalByArtist',
'newest',
'frequent',
'recent',
'starred',
'random',
]
const AlbumsList = () => {
const filter = useStoreDeep(store => store.settings.screens.library.albumsFilter)
const setFilter = useStore(store => store.setLibraryAlbumFilter)
const AlbumFilterButton = withSuspenseMemo(() => {
const { t } = useTranslation('resources.album.lists')
const filterType = useStoreDeep(store => store.settings.screens.library.albumsFilter.type)
const setFilterType = useStore(store => store.setLibraryAlbumFilterType)
const { isLoading, data, fetchNextPage, refetch } = useQueryAlbumList(filter.type as GetAlbumList2TypeBase, 300)
return (
<FilterButton
data={filterValues.map(value => ({ value, text: t(value) }))}
value={filterType}
onSelect={selection => {
setFilterType(selection as GetAlbumList2TypeBase)
}}
title={t('sort')}
/>
)
})
const AlbumsList = () => {
const filterType = useStoreDeep(store => store.settings.screens.library.albumsFilter.type)
const { isLoading, data, fetchNextPage, refetch } = useQueryAlbumList(filterType as GetAlbumList2TypeBase, 300)
const layout = useWindowDimensions()
@@ -91,16 +106,7 @@ const AlbumsList = () => {
onEndReachedThreshold={6}
windowSize={5}
/>
<FilterButton
data={filterOptions}
value={filter.type}
onSelect={selection => {
setFilter({
...filter,
type: selection as GetAlbumList2Type,
})
}}
/>
<AlbumFilterButton />
</View>
)
}

View File

@@ -1,26 +1,42 @@
import FilterButton, { OptionData } from '@app/components/FilterButton'
import FilterButton from '@app/components/FilterButton'
import GradientFlatList from '@app/components/GradientFlatList'
import ListItem from '@app/components/ListItem'
import { withSuspenseMemo } from '@app/components/withSuspense'
import { useQueryArtists } from '@app/hooks/query'
import { Artist } from '@app/models/library'
import { ArtistFilterType } from '@app/models/settings'
import { useStore, useStoreDeep } from '@app/state/store'
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { StyleSheet, View } from 'react-native'
const ArtistRenderItem: React.FC<{ item: Artist }> = ({ item }) => (
<ListItem item={item} showArt={true} showStar={false} listStyle="big" style={styles.listItem} />
)
const filterOptions: OptionData[] = [
{ text: 'By Name', value: 'alphabeticalByName' },
{ text: 'Starred', value: 'starred' },
{ text: 'Random', value: 'random' },
const filterValues: ArtistFilterType[] = [
'alphabeticalByName', //
'starred',
'random',
]
const ArtistFilterButton = withSuspenseMemo(() => {
const { t } = useTranslation('resources.artist.lists')
const filterType = useStoreDeep(store => store.settings.screens.library.artistsFilter.type)
const setFilterType = useStore(store => store.setLibraryArtistFilterType)
return (
<FilterButton
data={filterValues.map(value => ({ value, text: t(value) }))}
value={filterType}
onSelect={selection => setFilterType(selection as ArtistFilterType)}
title={t('sort')}
/>
)
})
const ArtistsList = () => {
const filter = useStoreDeep(store => store.settings.screens.library.artistsFilter)
const setFilter = useStore(store => store.setLibraryArtistFiler)
const filterType = useStore(store => store.settings.screens.library.artistsFilter.type)
const { isLoading, data, refetch } = useQueryArtists()
const [sortedList, setSortedList] = useState<Artist[]>([])
@@ -32,7 +48,7 @@ const ArtistsList = () => {
}
const list = Object.values(data.byId)
switch (filter.type) {
switch (filterType) {
case 'random':
setSortedList([...list].sort(() => Math.random() - 0.5))
break
@@ -46,7 +62,7 @@ const ArtistsList = () => {
setSortedList([...list])
break
}
}, [filter.type, data])
}, [filterType, data])
return (
<View style={styles.container}>
@@ -60,16 +76,7 @@ const ArtistsList = () => {
windowSize={3}
contentMarginTop={6}
/>
<FilterButton
data={filterOptions}
value={filter.type}
onSelect={selection => {
setFilter({
...filter,
type: selection as ArtistFilterType,
})
}}
/>
<ArtistFilterButton />
</View>
)
}

View File

@@ -3,9 +3,10 @@ import HeaderBar from '@app/components/HeaderBar'
import ImageGradientBackground from '@app/components/ImageGradientBackground'
import PressableOpacity from '@app/components/PressableOpacity'
import { PressableStar } from '@app/components/Star'
import { withSuspenseMemo } from '@app/components/withSuspense'
import { useNext, usePause, usePlay, usePrevious, useSeekTo } from '@app/hooks/trackplayer'
import { mapTrackExtToSong } from '@app/models/map'
import { QueueContextType, TrackExt } from '@app/models/trackplayer'
import { TrackExt } from '@app/models/trackplayer'
import { useStore, useStoreDeep } from '@app/state/store'
import colors from '@app/styles/colors'
import font from '@app/styles/font'
@@ -13,6 +14,7 @@ import formatDuration from '@app/util/formatDuration'
import Slider from '@react-native-community/slider'
import { useNavigation } from '@react-navigation/native'
import React, { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ActivityIndicator, StyleSheet, Text, TextStyle, View } from 'react-native'
import { NativeStackScreenProps } from 'react-native-screens/native-stack'
import { RepeatMode, State } from 'react-native-track-player'
@@ -21,32 +23,27 @@ import IconFA5 from 'react-native-vector-icons/FontAwesome5'
import Icon from 'react-native-vector-icons/Ionicons'
import IconMatCom from 'react-native-vector-icons/MaterialCommunityIcons'
function getContextName(type?: QueueContextType) {
switch (type) {
case 'album':
return 'Album'
case 'artist':
return 'Top Songs'
case 'playlist':
return 'Playlist'
case 'song':
return 'Search Results'
default:
return undefined
}
}
const NowPlayingHeader = React.memo<{
const NowPlayingHeader = withSuspenseMemo<{
track?: TrackExt
}>(({ track }) => {
const queueName = useStore(store => store.queueName)
const queueContextType = useStore(store => store.queueContextType)
const { t } = useTranslation()
if (!track) {
return <></>
}
let contextName = getContextName(queueContextType)
let contextName: string
if (queueContextType === 'album') {
contextName = t('resources.album.name')
} else if (queueContextType === 'artist') {
contextName = t('resources.song.lists.artistTopSongs')
} else if (queueContextType === 'playlist') {
contextName = t('resources.playlist.name')
} else if (queueContextType === 'song') {
contextName = t('search.nowPlayingContext')
}
return (
<HeaderBar

View File

@@ -4,6 +4,7 @@ import Header from '@app/components/Header'
import ListItem from '@app/components/ListItem'
import NothingHere from '@app/components/NothingHere'
import TextInput from '@app/components/TextInput'
import { withSuspense, withSuspenseMemo } from '@app/components/withSuspense'
import { useQuerySearchResults } from '@app/hooks/query'
import { useSetQueue } from '@app/hooks/trackplayer'
import { Album, Artist, SearchResults, Song } from '@app/models/library'
@@ -13,6 +14,7 @@ import { useFocusEffect, useNavigation } from '@react-navigation/native'
import equal from 'fast-deep-equal/es6/react'
import _ from 'lodash'
import React, { useCallback, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
ActivityIndicator,
InteractionManager,
@@ -39,56 +41,68 @@ const SongItem = React.memo<{ item: Song }>(({ item }) => {
)
}, equal)
const ResultsCategory = React.memo<{
const ResultsCategory = withSuspenseMemo<{
name: string
query: string
items: (Artist | Album | Song)[]
type: 'artist' | 'album' | 'song'
}>(({ name, query, type, items }) => {
const navigation = useNavigation()
}>(
({ name, query, type, items }) => {
const navigation = useNavigation()
const { t } = useTranslation('search')
if (items.length === 0) {
return <></>
}
if (items.length === 0) {
return <></>
}
return (
<>
<Header>{name}</Header>
{items.map(a =>
type === 'song' ? (
<SongItem key={a.id} item={a as Song} />
) : (
<ListItem key={a.id} item={a} showArt={true} showStar={false} />
),
)}
{items.length === 5 && (
<Button
title="More..."
buttonStyle="hollow"
style={styles.more}
onPress={() => navigation.navigate('results', { query, type: items[0].itemType })}
/>
)}
</>
)
}, equal)
return (
<>
<Header>{name}</Header>
{items.map(a =>
type === 'song' ? (
<SongItem key={a.id} item={a as Song} />
) : (
<ListItem key={a.id} item={a} showArt={true} showStar={false} />
),
)}
{items.length === 5 && (
<Button
title={t('moreResults')}
buttonStyle="hollow"
style={styles.more}
onPress={() => navigation.navigate('results', { query, type: items[0].itemType })}
/>
)}
</>
)
},
null,
equal,
)
const Results = React.memo<{
const Results = withSuspenseMemo<{
results: SearchResults
query: string
}>(({ results, query }) => {
return (
<>
<ResultsCategory name="Artists" query={query} type={'artist'} items={results.artists} />
<ResultsCategory name="Albums" query={query} type={'album'} items={results.albums} />
<ResultsCategory name="Songs" query={query} type={'song'} items={results.songs} />
</>
)
}, equal)
}>(
({ results, query }) => {
const { t } = useTranslation('resources')
const Search = () => {
return (
<>
<ResultsCategory name={t('artist.name', { count: 2 })} query={query} type={'artist'} items={results.artists} />
<ResultsCategory name={t('album.name', { count: 2 })} query={query} type={'album'} items={results.albums} />
<ResultsCategory name={t('song.name', { count: 2 })} query={query} type={'song'} items={results.songs} />
</>
)
},
null,
equal,
)
const Search = withSuspense(() => {
const [query, setQuery] = useState('')
const { data, isLoading } = useQuerySearchResults({ query, albumCount: 5, artistCount: 5, songCount: 5 })
const { t } = useTranslation('search')
const [text, setText] = useState('')
const searchBarRef = useRef<ReactTextInput>(null)
@@ -140,7 +154,7 @@ const Search = () => {
<TextInput
ref={searchBarRef}
style={styles.textInput}
placeholder="Search"
placeholder={t('inputPlaceholder')}
value={text}
onChangeText={onChangeText}
/>
@@ -154,7 +168,7 @@ const Search = () => {
</View>
</GradientScrollView>
)
}
})
const styles = StyleSheet.create({
scroll: {

View File

@@ -1,11 +1,13 @@
import GradientFlatList from '@app/components/GradientFlatList'
import ListItem from '@app/components/ListItem'
import { withSuspense } from '@app/components/withSuspense'
import { useQuerySearchResults } from '@app/hooks/query'
import { useSetQueue } from '@app/hooks/trackplayer'
import { Album, Artist, Song } from '@app/models/library'
import { Search3Params } from '@app/subsonic/params'
import { useNavigation } from '@react-navigation/native'
import React, { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { StyleSheet } from 'react-native'
type SearchListItemType = Album | Song | Artist
@@ -52,11 +54,12 @@ const ResultsListItem: React.FC<{ item: SearchListItemType }> = ({ item }) => {
const SearchResultsRenderItem: React.FC<{ item: SearchListItemType }> = ({ item }) => <ResultsListItem item={item} />
const SearchResultsView: React.FC<{
const SearchResultsView = withSuspense<{
query: string
type: 'album' | 'artist' | 'song'
}> = ({ query, type }) => {
}>(({ query, type }) => {
const navigation = useNavigation()
const { t } = useTranslation('search')
const size = 100
const params: Search3Params = { query }
@@ -82,7 +85,7 @@ const SearchResultsView: React.FC<{
useEffect(() => {
navigation.setOptions({
title: `Search: "${query}"`,
title: t('headerTitle', { query }),
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
@@ -102,7 +105,7 @@ const SearchResultsView: React.FC<{
windowSize={5}
/>
)
}
})
const styles = StyleSheet.create({
listItem: {

View File

@@ -1,5 +1,7 @@
import Button from '@app/components/Button'
import GradientScrollView from '@app/components/GradientScrollView'
import SettingsSwitch from '@app/components/SettingsSwitch'
import { withSuspense } from '@app/components/withSuspense'
import { Server } from '@app/models/settings'
import { useStore, useStoreDeep } from '@app/state/store'
import colors from '@app/styles/colors'
@@ -8,15 +10,16 @@ import toast from '@app/util/toast'
import { useNavigation } from '@react-navigation/native'
import md5 from 'md5'
import React, { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { StyleSheet, Text, TextInput, View, ViewStyle } from 'react-native'
import uuid from 'react-native-uuid'
import SettingsSwitch from '@app/components/SettingsSwitch'
const PASSWORD_PLACEHOLDER = 'PASSWORD_PLACEHOLDER'
const ServerView: React.FC<{
const ServerView = withSuspense<{
id?: string
}> = ({ id }) => {
}>(({ id }) => {
const { t } = useTranslation('settings.servers')
const navigation = useNavigation()
const activeServerId = useStore(store => store.settings.activeServerId)
const servers = useStoreDeep(store => store.settings.servers)
@@ -134,15 +137,16 @@ const ServerView: React.FC<{
const ping = async () => {
const res = await pingServer(potential)
if (res) {
toast(`Connection to ${potential.address} OK!`)
} else {
toast(`Connection to ${potential.address} failed, check settings or server`)
}
toast(
t(`messages.${res ? 'connectionOk' : 'connectionFailed'}`, {
address: potential.address,
interpolation: { escapeValue: false },
}),
)
setTesting(false)
}
ping()
}, [createServer, pingServer])
}, [createServer, pingServer, t])
const disableControls = useCallback(() => {
return !validate() || testing
@@ -169,7 +173,7 @@ const ServerView: React.FC<{
return (
<GradientScrollView style={styles.scroll}>
<View style={styles.content}>
<Text style={styles.inputTitle}>Address</Text>
<Text style={styles.inputTitle}>{t('fields.address')}</Text>
<TextInput
style={styles.input}
placeholderTextColor="grey"
@@ -182,7 +186,7 @@ const ServerView: React.FC<{
onChangeText={setAddress}
onBlur={formatAddress}
/>
<Text style={styles.inputTitle}>Username</Text>
<Text style={styles.inputTitle}>{t('fields.username')}</Text>
<TextInput
style={styles.input}
placeholderTextColor="grey"
@@ -195,7 +199,7 @@ const ServerView: React.FC<{
value={username}
onChangeText={setUsername}
/>
<Text style={styles.inputTitle}>Password</Text>
<Text style={styles.inputTitle}>{t('fields.password')}</Text>
<TextInput
style={styles.input}
placeholderTextColor="grey"
@@ -210,11 +214,11 @@ const ServerView: React.FC<{
onChangeText={setPassword}
/>
<SettingsSwitch
title="Force plain text password"
title={t('options.forcePlaintextPassword.title')}
subtitle={
usePlainPassword
? 'Send password in plain text (legacy, make sure your connection is secure!)'
: 'Send password as token + salt'
? t('options.forcePlaintextPassword.descriptionOn')
: t('options.forcePlaintextPassword.descriptionOff')
}
value={usePlainPassword}
setValue={togglePlainPassword}
@@ -222,21 +226,21 @@ const ServerView: React.FC<{
<Button
disabled={disableControls()}
style={styles.button}
title="Test Connection"
title={t('actions.testConnection')}
buttonStyle="hollow"
onPress={test}
/>
<Button
disabled={disableControls()}
style={[styles.button, styles.delete, deleteStyle]}
title="Delete"
title={t('actions.delete')}
onPress={remove}
/>
<Button disabled={disableControls()} style={styles.button} title="Save" onPress={save} />
<Button disabled={disableControls()} style={styles.button} title={t('actions.save')} onPress={save} />
</View>
</GradientScrollView>
)
}
})
const styles = StyleSheet.create({
scroll: {

View File

@@ -5,13 +5,15 @@ import PressableOpacity from '@app/components/PressableOpacity'
import SettingsItem from '@app/components/SettingsItem'
import SettingsSwitch from '@app/components/SettingsSwitch'
import TextInput from '@app/components/TextInput'
import { useSwitchActiveServer, useResetImageCache } from '@app/hooks/settings'
import { withSuspenseMemo } from '@app/components/withSuspense'
import { useResetImageCache, useSwitchActiveServer } from '@app/hooks/settings'
import { Server } from '@app/models/settings'
import { useStore, useStoreDeep } from '@app/state/store'
import colors from '@app/styles/colors'
import font from '@app/styles/font'
import { useNavigation } from '@react-navigation/core'
import React, { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { KeyboardTypeOptions, Linking, Modal, Pressable, StyleSheet, Text, View } from 'react-native'
import { ScrollView } from 'react-native-gesture-handler'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
@@ -73,25 +75,23 @@ const ModalChoice = React.memo<{
)
})
function bitrateString(bitrate: number): string {
return bitrate === 0 ? 'Unlimited' : `${bitrate}kbps`
}
const BitrateModal = React.memo<{
const BitrateModal = withSuspenseMemo<{
title: string
bitrate: number
setBitrate: (bitrate: number) => void
}>(({ title, bitrate, setBitrate }) => {
const { t } = useTranslation('settings.network.values')
const [visible, setVisible] = useState(false)
const toggleModal = useCallback(() => setVisible(!visible), [visible])
const bitrateText = useCallback((value: number) => (value === 0 ? t('unlimitedKbps') : t('kbps', { value })), [t])
const BitrateChoice: React.FC<{ value: number }> = useCallback(
({ value }) => {
const text = bitrateString(value)
return (
<ModalChoice
text={text}
text={bitrateText(value)}
value={value}
setValue={setBitrate}
closeModal={toggleModal}
@@ -99,12 +99,12 @@ const BitrateModal = React.memo<{
/>
)
},
[bitrate, toggleModal, setBitrate],
[bitrate, toggleModal, setBitrate, bitrateText],
)
return (
<>
<SettingsItem title={title} subtitle={bitrateString(bitrate)} onPress={toggleModal} />
<SettingsItem title={title} subtitle={bitrateText(bitrate)} onPress={toggleModal} />
<Modal animationType="fade" transparent={true} visible={visible} onRequestClose={toggleModal}>
<Pressable style={styles.modalBackdrop} onPress={toggleModal}>
<View style={styles.centeredView}>
@@ -135,9 +135,9 @@ const SettingsTextModal = React.memo<{
title: string
value: string
setValue: (text: string) => void
getUnit?: (text: string) => string
subtitle: (value: string) => string
keyboardType?: KeyboardTypeOptions
}>(({ title, value, setValue, getUnit, keyboardType }) => {
}>(({ title, value, setValue, subtitle, keyboardType }) => {
const [visible, setVisible] = useState(false)
const [inputText, setInputText] = useState(value)
@@ -148,16 +148,9 @@ const SettingsTextModal = React.memo<{
toggleModal()
}, [inputText, setValue, toggleModal])
const getSubtitle = useCallback(() => {
if (!getUnit) {
return value
}
return value + ' ' + getUnit(value)
}, [getUnit, value])
return (
<>
<SettingsItem title={title} subtitle={getSubtitle()} onPress={toggleModal} />
<SettingsItem title={title} subtitle={subtitle(value)} onPress={toggleModal} />
<Modal animationType="fade" transparent={true} visible={visible} onRequestClose={toggleModal}>
<Pressable style={styles.modalBackdrop} onPress={toggleModal}>
<View style={styles.centeredView}>
@@ -183,15 +176,9 @@ const SettingsTextModal = React.memo<{
)
})
function secondsUnit(seconds: string): string {
const numberValue = parseFloat(seconds)
if (Math.abs(numberValue) !== 1) {
return 'seconds'
}
return 'second'
}
const SettingsContent = withSuspenseMemo(() => {
const { t } = useTranslation('settings')
const SettingsContent = React.memo(() => {
const servers = useStoreDeep(store => store.settings.servers)
const scrobble = useStore(store => store.settings.scrobble)
const setScrobble = useStore(store => store.setScrobble)
@@ -221,66 +208,78 @@ const SettingsContent = React.memo(() => {
const setMinBufferText = useCallback((text: string) => setMinBuffer(parseFloat(text)), [setMinBuffer])
const setMaxBufferText = useCallback((text: string) => setMaxBuffer(parseFloat(text)), [setMaxBuffer])
const secondsText = useCallback((value: string) => t('network.values.seconds', { value }), [t])
return (
<View style={styles.content}>
<Header>Servers</Header>
<Header>{t('servers.name')}</Header>
{Object.values(servers).map(s => (
<ServerItem key={s.id} server={s} />
))}
<Button
style={styles.button}
title="Add Server"
title={t('servers.actions.add')}
onPress={() => navigation.navigate('server')}
buttonStyle="hollow"
/>
<Header style={styles.header}>Network</Header>
<BitrateModal title="Maximum bitrate (Wi-Fi)" bitrate={maxBitrateWifi} setBitrate={setMaxBitrateWifi} />
<BitrateModal title="Maximum bitrate (mobile)" bitrate={maxBitrateMobile} setBitrate={setMaxBitrateMobile} />
<Header style={styles.header}>{t('network.name')}</Header>
<BitrateModal
title={t('network.options.maxBitrateWifi.title')}
bitrate={maxBitrateWifi}
setBitrate={setMaxBitrateWifi}
/>
<BitrateModal
title={t('network.options.maxBitrateMobile.title')}
bitrate={maxBitrateMobile}
setBitrate={setMaxBitrateMobile}
/>
<SettingsTextModal
title="Minimum buffer time"
title={t('network.options.minBuffer.title')}
value={minBuffer.toString()}
setValue={setMinBufferText}
getUnit={secondsUnit}
subtitle={secondsText}
keyboardType="numeric"
/>
<SettingsTextModal
title="Maximum buffer time"
title={t('network.options.maxBuffer.title')}
value={maxBuffer.toString()}
setValue={setMaxBufferText}
getUnit={secondsUnit}
subtitle={secondsText}
keyboardType="numeric"
/>
<Header style={styles.header}>Music</Header>
<Header style={styles.header}>{t('music.name')}</Header>
<SettingsSwitch
title="Scrobble plays"
subtitle={scrobble ? 'Scrobble play history' : "Don't scrobble play history"}
title={t('music.options.scrobble.title')}
subtitle={scrobble ? t('music.options.scrobble.descriptionOn') : t('music.options.scrobble.descriptionOff')}
value={scrobble}
setValue={setScrobble}
/>
<Header style={styles.header}>Reset</Header>
<Header style={styles.header}>{t('reset.name')}</Header>
<Button
disabled={clearing}
style={styles.button}
title="Clear Image Cache"
title={t('reset.actions.clearImageCache')}
onPress={clear}
buttonStyle="hollow"
/>
<Header style={styles.header}>About</Header>
<Header style={styles.header}>{t('about.name')}</Header>
<Text style={styles.text}>
<Text style={styles.bold}>Subtracks</Text> version {version}
<Text style={styles.bold}>Subtracks</Text> {t('about.version', { version })}
</Text>
<Button
disabled={clearing}
style={styles.button}
title="Project Homepage"
title={t('about.actions.projectHomepage')}
onPress={() => Linking.openURL('https://github.com/austinried/subtracks')}
buttonStyle="hollow"
/>
<Button
disabled={clearing}
style={styles.button}
title="Licenses"
onPress={() => navigation.navigate('web', { uri: 'file:///android_asset/licenses.html' })}
title={t('about.actions.licenses')}
onPress={() =>
navigation.navigate('web', { uri: 'file:///android_asset/licenses.html', title: t('about.actions.licenses') })
}
buttonStyle="hollow"
/>
</View>

View File

@@ -56,10 +56,8 @@ const SongListDetails = React.memo<{
const [headerColor, setHeaderColor] = useState<string | undefined>(undefined)
const _songs = [...(songs || [])]
let typeName = ''
if (type === 'album') {
typeName = 'Album'
if (_songs.some(s => s.track === undefined)) {
_songs.sort((a, b) => a.title.localeCompare(b.title))
} else {
@@ -69,8 +67,6 @@ const SongListDetails = React.memo<{
return aVal - bVal
})
}
} else {
typeName = 'Playlist'
}
const { setQueue, isReady, contextId } = useSetQueue(type, _songs)
@@ -125,7 +121,7 @@ const SongListDetails = React.memo<{
<ListPlayerControls
style={styles.controls}
songs={_songs}
typeName={typeName}
listType={type}
play={play(undefined, false)}
shuffle={play(undefined, true)}
disabled={disabled}

View File

@@ -1,9 +1,17 @@
import React from 'react'
import { useNavigation } from '@react-navigation/native'
import React, { useEffect } from 'react'
import { WebView } from 'react-native-webview'
const WebViewScreen: React.FC<{
uri: string
}> = ({ uri }) => {
title?: string
}> = ({ uri, title }) => {
const navigation = useNavigation()
useEffect(() => {
navigation.setOptions({ title })
}, [navigation, title])
return <WebView source={{ uri }} />
}