Update React Native to 0.79.2, Bump Dependencies, Updates to Settings Tab, Add opt in Logging and telemetry (#338)

Welcome to Jellify 0.12.2!

This update comes with a much anticipated developer feature - and that is opt-in logging and telemetry. This is purely an opt-in feature, and can be toggled at anytime either when logging in for the first time, or in the settings tab. Under no circumstances will this ever be required to use Jellify, and all data that is collected is anonymized

The Settings tab has had a facelift, stealing design queues from the library page. This is where adjustable settings will make their home in updates to come, as well as how beta testing larger features will occur with “Labs”

This update also fixes issues with playback reporting, where the Jellyfin server wasn’t marking songs as played. This was not intended and has been fixed for those using the Last.FM plugin for scrobbling

There is also a slew of upgrades to underlying dependencies to make sure we are up to date on that front 

Thanks for reading! ~Violet
This commit is contained in:
Violet Caulfield
2025-05-10 03:01:06 -05:00
committed by GitHub
parent 64360d734d
commit b455208588
83 changed files with 3274 additions and 2865 deletions

View File

@@ -29,6 +29,29 @@ jobs:
- name: 🤫 Output App Store Connect API Key JSON to Fastlane
run: echo -e '${{ secrets.APPSTORE_CONNECT_API_KEY_JSON }}' > appstore_connect_api_key.json
working-directory: ./ios/fastlane
- name: 🤫 Output TelemetryDeck Secrets to TelemetryDeck.json
run: |
echo "{" > telemetrydeck.json
echo "\"appID\": \"${{ secrets.TELEMETRYDECK_APPID }}\"," >> telemetrydeck.json
echo "\"clientUser\": \"anonymous\"," >> telemetrydeck.json
echo "\"app\": \"Jellify\"" >> telemetrydeck.json
echo "}" >> telemetrydeck.json
- name: 🤫 Output Glitchtip Secrets to Glitchtip.json
run: |
echo "{" > glitchtip.json
echo "\"dsn\": \"${{ secrets.GLITCHTIP_DSN }}\"" >> glitchtip.json
echo "}" >> glitchtip.json
- name: ✅ Validate TelemetryDeck.json
run: |
node -e "JSON.parse(require('fs').readFileSync('telemetrydeck.json'))"
- name: ✅ Validate Glitchtip.json
run: |
node -e "JSON.parse(require('fs').readFileSync('glitchtip.json'))"
- name: 🚀 Run Android fastlane build
run: yarn fastlane:android:build
@@ -41,11 +64,19 @@ jobs:
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_REPO_PAT: "anultravioletaurora:${{ secrets.SIGNING_REPO_PAT }}"
- name: 👩‍💻 Configure Git
run: |
git config --global user.email "violet@cosmonautical.cloud"
git config --global user.name "anultravioletaurora"
- name: 🧹 Clean up Glitchtip and TelemetryDeck files
run: |
git restore telemetrydeck.json
git restore glitchtip.json
# Commit Fastlane Xcode build number increment
- name: 🔢 Commit changes for version increment
run: |
git config --global user.email "jellify@cosmonautical.com"
git config --global user.name "anultravioletaurora"
git add package.json
git add ios/Jellify.xcodeproj/project.pbxproj
git add android/app/build.gradle
@@ -77,9 +108,6 @@ jobs:
prerelease: true
tag: ${{ env.VERSION_NUMBER }}
token: ${{ secrets.SIGNING_REPO_PAT }}
- name: 🗣️ Notify on Discord
run: |

2
.gitignore vendored
View File

@@ -76,4 +76,4 @@ yarn-error.log
# Expo
.expo
dist/
web-build/
web-build/

View File

@@ -19,6 +19,10 @@ import { requestStoragePermission } from './src/helpers/permisson-helpers'
import ErrorBoundary from './src/components/ErrorBoundary'
import Toast from 'react-native-toast-message'
import JellifyToastConfig from './src/constants/toast.config'
import { TelemetryDeckProvider, createTelemetryDeck } from '@typedigital/telemetrydeck-react'
import telemetryDeckConfig from './telemetrydeck.json'
import glitchtipConfig from './glitchtip.json'
import * as Sentry from '@sentry/react-native'
export const backgroundRuntime = createWorkletRuntime('background')
@@ -36,6 +40,7 @@ export default function App(): React.JSX.Element {
capabilities: CAPABILITIES,
notificationCapabilities: CAPABILITIES,
compactCapabilities: CAPABILITIES,
progressUpdateEventInterval: 10,
}),
)
.finally(() => {

View File

@@ -140,9 +140,10 @@ Playlist
[React Native Track Player](https://github.com/doublesymmetry/react-native-track-player)\
[React Native URL Polyfill](https://github.com/charpeni/react-native-url-polyfill)
### 👩‍💻 Monitoring
### 👩‍💻 *Opt-In* Monitoring
[GlitchTip](https://glitchtip.com/)
[GlitchTip](https://glitchtip.com/)\
[TelemetryDeck](https://telemetrydeck.com)
### 💜 Love from Wisconsin 🧀

View File

@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME

58
eslint.config.js Normal file
View File

@@ -0,0 +1,58 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const { defineConfig } = require('eslint/config')
const tsParser = require('@typescript-eslint/parser')
const typescriptEslint = require('@typescript-eslint/eslint-plugin')
const react = require('eslint-plugin-react')
const globals = require('globals')
const js = require('@eslint/js')
const { FlatCompat } = require('@eslint/eslintrc')
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
allConfig: js.configs.all,
})
module.exports = defineConfig([
{
extends: compat.extends(
'eslint:recommended',
'plugin:react/recommended',
'plugin:@typescript-eslint/recommended',
),
languageOptions: {
parser: tsParser,
globals: {
...globals.browser,
...globals.node,
...globals.jest,
},
},
plugins: {
'@typescript-eslint': typescriptEslint,
react,
},
rules: {
'react/react-in-jsx-scope': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/no-require-imports': 'off',
'@typescript-eslint/no-empty-object-type': 'off',
'react/prop-types': 'off',
'@typescript-eslint/no-explicit-any': 'error',
'no-mixed-spaces-and-tabs': 'off',
semi: ['error', 'never'],
},
settings: {
react: {
version: 'detect',
},
},
},
])

3
glitchtip.json Normal file
View File

@@ -0,0 +1,3 @@
{
"dsn": "https://glitchtip.jellify.app"
}

View File

@@ -11,7 +11,7 @@
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
217EBE16A3E8C5FBF476C905 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = F757EB73303E0AC21EF34F64 /* PrivacyInfo.xcprivacy */; };
81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; };
92C580068317633958E4B0F9 /* libPods-Jellify.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 4912CEDF8E675A9B3515C88E /* libPods-Jellify.a */; };
9D70AF74B3F54D679E527364 /* libPods-Jellify.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 95B4030EF5077AD57C505857 /* libPods-Jellify.a */; };
CF620D0C2CF2BB210045E433 /* Aileron-Italic.otf in Resources */ = {isa = PBXBuildFile; fileRef = CF620CFC2CF2BB1F0045E433 /* Aileron-Italic.otf */; };
CF620D0D2CF2BB210045E433 /* Aileron-Thin.otf in Resources */ = {isa = PBXBuildFile; fileRef = CF620CFD2CF2BB1F0045E433 /* Aileron-Thin.otf */; };
CF620D0E2CF2BB210045E433 /* Aileron-HeavyItalic.otf in Resources */ = {isa = PBXBuildFile; fileRef = CF620CFE2CF2BB1F0045E433 /* Aileron-HeavyItalic.otf */; };
@@ -79,10 +79,10 @@
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = Jellify/Images.xcassets; sourceTree = "<group>"; };
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = Jellify/Info.plist; sourceTree = "<group>"; };
13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = PrivacyInfo.xcprivacy; path = Jellify/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
4912CEDF8E675A9B3515C88E /* libPods-Jellify.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Jellify.a"; sourceTree = BUILT_PRODUCTS_DIR; };
1D5EDD3ECA2154FB05E36C5D /* Pods-Jellify.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Jellify.debug.xcconfig"; path = "Target Support Files/Pods-Jellify/Pods-Jellify.debug.xcconfig"; sourceTree = "<group>"; };
81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = Jellify/LaunchScreen.storyboard; sourceTree = "<group>"; };
A4403789D3D6FBE6706E62B4 /* Pods-Jellify.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Jellify.release.xcconfig"; path = "Target Support Files/Pods-Jellify/Pods-Jellify.release.xcconfig"; sourceTree = "<group>"; };
ACD0D4797EFB0AA1C5B6FC7D /* Pods-Jellify.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Jellify.debug.xcconfig"; path = "Target Support Files/Pods-Jellify/Pods-Jellify.debug.xcconfig"; sourceTree = "<group>"; };
95B4030EF5077AD57C505857 /* libPods-Jellify.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Jellify.a"; sourceTree = BUILT_PRODUCTS_DIR; };
A42F7FBA73F634466F310088 /* Pods-Jellify.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Jellify.release.xcconfig"; path = "Target Support Files/Pods-Jellify/Pods-Jellify.release.xcconfig"; sourceTree = "<group>"; };
CF620CFC2CF2BB1F0045E433 /* Aileron-Italic.otf */ = {isa = PBXFileReference; lastKnownFileType = file; name = "Aileron-Italic.otf"; path = "../assets/fonts/Aileron-Italic.otf"; sourceTree = "<group>"; };
CF620CFD2CF2BB1F0045E433 /* Aileron-Thin.otf */ = {isa = PBXFileReference; lastKnownFileType = file; name = "Aileron-Thin.otf"; path = "../assets/fonts/Aileron-Thin.otf"; sourceTree = "<group>"; };
CF620CFE2CF2BB1F0045E433 /* Aileron-HeavyItalic.otf */ = {isa = PBXFileReference; lastKnownFileType = file; name = "Aileron-HeavyItalic.otf"; path = "../assets/fonts/Aileron-HeavyItalic.otf"; sourceTree = "<group>"; };
@@ -164,7 +164,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
92C580068317633958E4B0F9 /* libPods-Jellify.a in Frameworks */,
9D70AF74B3F54D679E527364 /* libPods-Jellify.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -209,7 +209,7 @@
isa = PBXGroup;
children = (
ED297162215061F000B7C4FE /* JavaScriptCore.framework */,
4912CEDF8E675A9B3515C88E /* libPods-Jellify.a */,
95B4030EF5077AD57C505857 /* libPods-Jellify.a */,
);
name = Frameworks;
sourceTree = "<group>";
@@ -265,8 +265,8 @@
BBD78D7AC51CEA395F1C20DB /* Pods */ = {
isa = PBXGroup;
children = (
ACD0D4797EFB0AA1C5B6FC7D /* Pods-Jellify.debug.xcconfig */,
A4403789D3D6FBE6706E62B4 /* Pods-Jellify.release.xcconfig */,
1D5EDD3ECA2154FB05E36C5D /* Pods-Jellify.debug.xcconfig */,
A42F7FBA73F634466F310088 /* Pods-Jellify.release.xcconfig */,
);
path = Pods;
sourceTree = "<group>";
@@ -356,13 +356,13 @@
isa = PBXNativeTarget;
buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Jellify" */;
buildPhases = (
B44EEE05F9243658C0ACC188 /* [CP] Check Pods Manifest.lock */,
F74F914889A8D884A0B6CADF /* [CP] Check Pods Manifest.lock */,
13B07F871A680F5B00A75B9A /* Sources */,
13B07F8C1A680F5B00A75B9A /* Frameworks */,
13B07F8E1A680F5B00A75B9A /* Resources */,
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */,
18CA487EA4EA691EF7ACA31A /* [CP] Embed Pods Frameworks */,
0949ABFBA66388712DB5BAC4 /* [CP] Copy Pods Resources */,
1E8DEA05AEB2DD976158686F /* [CP] Embed Pods Frameworks */,
20B672AD2C4E05F9590DDE44 /* [CP] Copy Pods Resources */,
);
buildRules = (
);
@@ -491,24 +491,7 @@
shellPath = /bin/sh;
shellScript = "set -e\n\nWITH_ENVIRONMENT=\"$REACT_NATIVE_PATH/scripts/xcode/with-environment.sh\"\nREACT_NATIVE_XCODE=\"$REACT_NATIVE_PATH/scripts/react-native-xcode.sh\"\n\n/bin/sh -c \"$WITH_ENVIRONMENT $REACT_NATIVE_XCODE\"\n";
};
0949ABFBA66388712DB5BAC4 /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Jellify/Pods-Jellify-resources-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Jellify/Pods-Jellify-resources-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Jellify/Pods-Jellify-resources.sh\"\n";
showEnvVarsInLog = 0;
};
18CA487EA4EA691EF7ACA31A /* [CP] Embed Pods Frameworks */ = {
1E8DEA05AEB2DD976158686F /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
@@ -525,7 +508,24 @@
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Jellify/Pods-Jellify-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
B44EEE05F9243658C0ACC188 /* [CP] Check Pods Manifest.lock */ = {
20B672AD2C4E05F9590DDE44 /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Jellify/Pods-Jellify-resources-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Jellify/Pods-Jellify-resources-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Jellify/Pods-Jellify-resources.sh\"\n";
showEnvVarsInLog = 0;
};
F74F914889A8D884A0B6CADF /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
@@ -634,7 +634,7 @@
};
13B07F941A680F5B00A75B9A /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = ACD0D4797EFB0AA1C5B6FC7D /* Pods-Jellify.debug.xcconfig */;
baseConfigurationReference = 1D5EDD3ECA2154FB05E36C5D /* Pods-Jellify.debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
@@ -676,7 +676,7 @@
};
13B07F951A680F5B00A75B9A /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = A4403789D3D6FBE6706E62B4 /* Pods-Jellify.release.xcconfig */;
baseConfigurationReference = A42F7FBA73F634466F310088 /* Pods-Jellify.release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,7 @@ module.exports = {
'./jest/setup-reanimated.ts',
'./jest/setup-rnfs.ts',
'./jest/setup-rntp.ts',
'./jest/setup-sentry.ts',
'./tamagui.config.ts',
'./jest/setup-native-modules.ts',
],

3
jest/setup-sentry.ts Normal file
View File

@@ -0,0 +1,3 @@
jest.mock('@sentry/react-native', () => ({
init: jest.fn(),
}))

View File

@@ -1,120 +1,126 @@
{
"name": "jellify",
"version": "0.12.1",
"private": true,
"scripts": {
"init-android": "yarn",
"init-ios": "yarn init-ios:new-arch",
"init-ios:new-arch": "yarn && yarn pod:install:new-arch",
"reinstall": "rm -rf ./node_modules && yarn install",
"android": "react-native run-android",
"ios": "react-native run-ios",
"lint": "eslint .",
"start": "react-native start",
"test": "jest",
"tsc": "tsc",
"clean:ios": "cd ios && pod deintegrate",
"clean:android": "cd android && rm -rf app/ build/",
"pod:install": "echo 'Please run `yarn pod:install:new-arch` to enable the new architecture'",
"pod:install:new-arch": "cd ios && bundle install && RCT_NEW_ARCH_ENABLED=1 bundle exec pod install",
"pod:clean": "cd ios && pod deintegrate",
"fastlane:ios:build": "cd ios && bundle exec fastlane build",
"fastlane:ios:match": "cd ios && bundle exec fastlane match development",
"fastlane:ios:beta": "cd ios && bundle exec fastlane beta",
"fastlane:android:build": "cd android && bundle install && bundle exec fastlane build",
"androidBuild": "cd android && ./gradlew clean && ./gradlew assembleRelease && cd .. && echo 'find apk in android/app/build/outputs/apk/release'",
"prepare": "husky",
"format:check": "prettier --check .",
"format": "prettier --write .",
"postinstall": "patch-package"
},
"dependencies": {
"@jellyfin/sdk": "^0.11.0",
"@react-native-community/blur": "^4.4.1",
"@react-native-community/cli": "^18.0.0",
"@react-native-community/netinfo": "^11.4.1",
"@react-native-masked-view/masked-view": "^0.3.2",
"@react-navigation/bottom-tabs": "^7.3.10",
"@react-navigation/material-top-tabs": "^7.2.10",
"@react-navigation/native": "^7.1.6",
"@react-navigation/native-stack": "^7.3.10",
"@react-navigation/stack": "^7.2.10",
"@tamagui/config": "^1.126.4",
"@tanstack/query-sync-storage-persister": "^5.74.6",
"@tanstack/react-query": "^5.74.4",
"@tanstack/react-query-persist-client": "^5.74.6",
"@testing-library/react-native": "^13.2.0",
"axios": "^1.9.0",
"bundle": "^2.1.0",
"gem": "^2.4.3",
"invert-color": "^2.0.0",
"lodash": "^4.17.21",
"react": "19.0.0",
"react-freeze": "^1.0.4",
"react-native": "0.79.1",
"react-native-background-actions": "^4.0.1",
"react-native-carplay": "^2.4.1-beta.0",
"react-native-device-info": "^14.0.4",
"react-native-draggable-flatlist": "^4.0.2",
"react-native-fast-image": "^8.6.3",
"react-native-fs": "^2.20.0",
"react-native-gesture-handler": "^2.25.0",
"react-native-haptic-feedback": "^2.3.3",
"react-native-mmkv": "3.2.0",
"react-native-pager-view": "^6.7.1",
"react-native-reanimated": "^3.17.5",
"react-native-safe-area-context": "^5.4.0",
"react-native-screens": "^4.11.0-beta.2",
"react-native-swipeable-item": "^2.0.9",
"react-native-text-ticker": "^1.14.0",
"react-native-toast-message": "^2.3.0",
"react-native-track-player": "git+https://github.com/riteshshukla04/react-native-track-player.git#APM",
"react-native-url-polyfill": "^2.0.0",
"react-native-uuid": "^2.0.3",
"react-native-vector-icons": "^10.2.0",
"ruby": "^0.6.1",
"tamagui": "^1.126.4"
},
"devDependencies": {
"@babel/core": "^7.27.1",
"@babel/preset-env": "^7.27.1",
"@babel/runtime": "^7.27.1",
"@react-native-community/cli-platform-android": "18.0.0",
"@react-native-community/cli-platform-ios": "18.0.0",
"@react-native/babel-preset": "0.79.1",
"@react-native/eslint-config": "0.79.1",
"@react-native/metro-config": "0.79.1",
"@react-native/typescript-config": "0.79.1",
"@types/jest": "^29.5.13",
"@types/lodash": "^4.17.10",
"@types/react": "^19.1.2",
"@types/react-native-vector-icons": "^6.4.18",
"@types/react-test-renderer": "19.0.0",
"babel-plugin-module-resolver": "^5.0.2",
"eslint": "^8.57.1",
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-prettier": "^5.2.6",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-native": "^5.0.0",
"husky": "^9.1.7",
"jest": "^29.6.3",
"jscodeshift": "^0.15.2",
"lint-staged": "^15.5.1",
"patch-package": "8.0.0",
"prettier": "^3.5.3",
"react-dom": "^19.0.0",
"react-native-cli-bump-version": "^1.5.1",
"react-test-renderer": "19.0.0",
"typescript": "5.8.3"
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"prettier --write",
"eslint --fix"
]
},
"engines": {
"node": ">=18"
}
}
"name": "jellify",
"version": "0.12.1",
"private": true,
"scripts": {
"init-android": "yarn",
"init-ios": "yarn init-ios:new-arch",
"init-ios:new-arch": "yarn && yarn pod:install:new-arch",
"reinstall": "rm -rf ./node_modules && yarn install",
"android": "react-native run-android",
"ios": "react-native run-ios",
"lint": "eslint .",
"start": "react-native start",
"test": "jest",
"tsc": "tsc",
"clean:ios": "cd ios && pod deintegrate",
"clean:android": "cd android && rm -rf app/ build/",
"pod:install": "echo 'Please run `yarn pod:install:new-arch` to enable the new architecture'",
"pod:install:new-arch": "cd ios && bundle install && RCT_NEW_ARCH_ENABLED=1 bundle exec pod install",
"pod:clean": "cd ios && pod deintegrate",
"fastlane:ios:build": "cd ios && bundle exec fastlane build",
"fastlane:ios:match": "cd ios && bundle exec fastlane match development",
"fastlane:ios:beta": "cd ios && bundle exec fastlane beta",
"fastlane:android:build": "cd android && bundle install && bundle exec fastlane build",
"androidBuild": "cd android && ./gradlew clean && ./gradlew assembleRelease && cd .. && echo 'find apk in android/app/build/outputs/apk/release'",
"prepare": "husky",
"format:check": "prettier --check .",
"format": "prettier --write .",
"postinstall": "patch-package"
},
"dependencies": {
"@jellyfin/sdk": "^0.11.0",
"@react-native-community/blur": "^4.4.1",
"@react-native-community/cli": "^18.0.0",
"@react-native-community/netinfo": "^11.4.1",
"@react-native-masked-view/masked-view": "^0.3.2",
"@react-navigation/bottom-tabs": "^7.3.12",
"@react-navigation/material-top-tabs": "^7.2.12",
"@react-navigation/native": "^7.1.8",
"@react-navigation/native-stack": "^7.3.12",
"@react-navigation/stack": "^7.3.1",
"@sentry/react-native": "^6.13.1",
"@tamagui/config": "^1.126.9",
"@tanstack/query-sync-storage-persister": "^5.75.7",
"@tanstack/react-query": "^5.75.7",
"@tanstack/react-query-persist-client": "^5.75.7",
"@testing-library/react-native": "^13.2.0",
"@typedigital/telemetrydeck-react": "^0.2.0",
"axios": "^1.9.0",
"bundle": "^2.1.0",
"dlx": "^0.2.1",
"gem": "^2.4.3",
"invert-color": "^2.0.0",
"lodash": "^4.17.21",
"react": "19.0.0",
"react-freeze": "^1.0.4",
"react-native": "0.79.2",
"react-native-background-actions": "^4.0.1",
"react-native-carplay": "^2.4.1-beta.0",
"react-native-device-info": "^14.0.4",
"react-native-draggable-flatlist": "^4.0.3",
"react-native-fast-image": "^8.6.3",
"react-native-fs": "^2.20.0",
"react-native-gesture-handler": "^2.25.0",
"react-native-haptic-feedback": "^2.3.3",
"react-native-mmkv": "3.2.0",
"react-native-pager-view": "^6.7.1",
"react-native-reanimated": "^3.17.5",
"react-native-safe-area-context": "^5.4.0",
"react-native-screens": "^4.11.0-beta.2",
"react-native-swipeable-item": "^2.0.9",
"react-native-text-ticker": "^1.14.0",
"react-native-toast-message": "^2.3.0",
"react-native-track-player": "git+https://github.com/riteshshukla04/react-native-track-player.git#APM",
"react-native-url-polyfill": "^2.0.0",
"react-native-uuid": "^2.0.3",
"react-native-vector-icons": "^10.2.0",
"ruby": "^0.6.1",
"tamagui": "^1.126.9"
},
"devDependencies": {
"@babel/core": "^7.27.1",
"@babel/preset-env": "^7.27.1",
"@babel/runtime": "^7.27.1",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.26.0",
"@react-native-community/cli-platform-android": "18.0.0",
"@react-native-community/cli-platform-ios": "18.0.0",
"@react-native/babel-preset": "0.79.2",
"@react-native/eslint-config": "0.79.2",
"@react-native/metro-config": "0.79.2",
"@react-native/typescript-config": "0.79.2",
"@types/jest": "^29.5.14",
"@types/lodash": "^4.17.16",
"@types/react": "^19.1.3",
"@types/react-native-vector-icons": "^6.4.18",
"@types/react-test-renderer": "19.0.0",
"babel-plugin-module-resolver": "^5.0.2",
"eslint": "^9.26.0",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-prettier": "^5.4.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-native": "^5.0.0",
"globals": "^16.1.0",
"husky": "^9.1.7",
"jest": "^29.7.0",
"jscodeshift": "^0.15.2",
"lint-staged": "^15.5.2",
"patch-package": "8.0.0",
"prettier": "^3.5.3",
"react-dom": "^19.0.0",
"react-native-cli-bump-version": "^1.5.1",
"react-test-renderer": "19.0.0",
"typescript": "5.8.3"
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"prettier --write",
"eslint --fix"
]
},
"engines": {
"node": ">=18"
}
}

View File

@@ -12,7 +12,7 @@ import TextTicker from 'react-native-text-ticker'
import { TextTickerConfig } from '../Player/component.config'
import FastImage from 'react-native-fast-image'
import { getImageApi } from '@jellyfin/sdk/lib/utils/api'
import Icon from '../Global/helpers/icon'
import Icon from '../Global/components/icon'
import { Platform, useColorScheme } from 'react-native'
import JellifyToastConfig from '../../constants/toast.config'
import Toast from 'react-native-toast-message'

View File

@@ -4,8 +4,8 @@ import HorizontalCardList from '../../../components/Global/components/horizontal
import { ItemCard } from '../../../components/Global/components/item-card'
import { useDiscoverContext } from '../../../providers/Discover'
import { View, XStack } from 'tamagui'
import { H2 } from '../../../components/Global/helpers/text'
import Icon from '../../../components/Global/helpers/icon'
import { H2, H4 } from '../../../components/Global/helpers/text'
import Icon from '../../Global/components/icon'
export default function RecentlyAdded({
navigation,
@@ -29,12 +29,11 @@ export default function RecentlyAdded({
})
}}
>
<H2 marginLeft={'$2'}>Recently Added</H2>
<H4 marginLeft={'$2'}>Recently Added</H4>
<Icon name='arrow-right' />
</XStack>
<HorizontalCardList
squared
data={
(recentlyAdded?.pages[0].length ?? 0 > 10)
? recentlyAdded!.pages[0].slice(0, 10)

View File

@@ -1,6 +1,6 @@
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import React, { useEffect, useState } from 'react'
import Icon from '../helpers/icon'
import Icon from './icon'
import { useQuery } from '@tanstack/react-query'
import { isUndefined } from 'lodash'
import { getTokens, Spinner } from 'tamagui'
@@ -43,7 +43,7 @@ export default function FavoriteButton({
) : (
<Icon
name={isFavorite ? 'heart' : 'heart-outline'}
color={getTokens().color.telemagenta.val}
color={'$primary'}
onPress={() =>
toggleFavorite(isFavorite, {
item,

View File

@@ -1,6 +1,6 @@
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { getToken, Spacer, YStack } from 'tamagui'
import Icon from '../helpers/icon'
import Icon from './icon'
import { useQuery } from '@tanstack/react-query'
import { QueryKeys } from '../../../enums/query-keys'
import { fetchUserData } from '../../../api/queries/favorites'
@@ -22,11 +22,7 @@ export default function FavoriteIcon({ item }: { item: BaseItemDto }): React.JSX
return (
<YStack alignContent='center' justifyContent='center' minWidth={24}>
{isFavorite ? (
<Icon small name='heart' color={getToken('$color.telemagenta')} />
) : (
<Spacer />
)}
{isFavorite ? <Icon small name='heart' color={'$primary'} /> : <Spacer />}
</YStack>
)
}

View File

@@ -1,17 +1,8 @@
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto'
import React from 'react'
import { FlatList, FlatListProps, ListRenderItem } from 'react-native'
import IconCard from '../helpers/icon-card'
interface HorizontalCardListProps extends FlatListProps<BaseItemDto> {
squared?: boolean | undefined
/**
* The number of items that will be displayed before
* we cut it off and display a "Show More" card
*/
cutoff?: number | undefined
onSeeMore?: () => void | undefined
}
interface HorizontalCardListProps extends FlatListProps<BaseItemDto> {}
/**
* Displays a Horizontal FlatList of 20 ItemCards
@@ -20,8 +11,6 @@ interface HorizontalCardListProps extends FlatListProps<BaseItemDto> {
* @returns
*/
export default function HorizontalCardList({
onSeeMore,
squared = false,
...props
}: HorizontalCardListProps): React.JSX.Element {
return (
@@ -29,16 +18,6 @@ export default function HorizontalCardList({
horizontal
data={props.data}
renderItem={props.renderItem}
ListFooterComponent={() => {
return props.data && onSeeMore ? (
<IconCard
name={squared ? 'arrow-right-box' : 'arrow-right-circle'}
circular={!squared}
caption='See More'
onPress={onSeeMore}
/>
) : undefined
}}
removeClippedSubviews
style={{
overflow: 'hidden',

View File

@@ -1,6 +1,6 @@
import React from 'react'
import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons'
import { useTheme } from 'tamagui'
import { ColorTokens, getToken, getTokens, themeable, ThemeTokens, Tokens, useTheme } from 'tamagui'
const smallSize = 24
@@ -25,14 +25,14 @@ export default function Icon({
large?: boolean
disabled?: boolean
extraLarge?: boolean
color?: string | undefined
color?: ThemeTokens | undefined
}): React.JSX.Element {
const theme = useTheme()
const size = extraLarge ? extraLargeSize : large ? largeSize : small ? smallSize : regularSize
return (
<MaterialCommunityIcons
color={color ? color : theme.color.val}
color={color ? theme[color]?.val : theme.color.val}
name={name}
onPress={onPress}
disabled={disabled}

View File

@@ -5,9 +5,8 @@ import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { QueryKeys } from '../../../enums/query-keys'
import { useQuery } from '@tanstack/react-query'
import { fetchInstantMixFromItem } from '../../../api/queries/instant-mixes'
import Icon from '../helpers/icon'
import { getToken, Spacer, Spinner } from 'tamagui'
import { useColorScheme } from 'react-native'
import Icon from './icon'
import { Spacer, Spinner } from 'tamagui'
import { useJellifyContext } from '../../../providers'
export default function InstantMixButton({
item,
@@ -23,11 +22,10 @@ export default function InstantMixButton({
staleTime: 1000 * 60 * 60 * 24, // 24 hours
})
const isDarkMode = useColorScheme() === 'dark'
return data ? (
<Icon
name='compass-outline'
color={isDarkMode ? getToken('$color.success') : getToken('$color.grape')}
color={'$success'}
onPress={() =>
navigation.navigate('InstantMix', {
item,

View File

@@ -4,7 +4,7 @@ import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { getTokens, Separator, Spacer, View, XStack, YStack } from 'tamagui'
import { Text } from '../helpers/text'
import { useSafeAreaFrame } from 'react-native-safe-area-context'
import Icon from '../helpers/icon'
import Icon from './icon'
import { QueuingType } from '../../../enums/queuing-type'
import { RunTimeTicks } from '../helpers/time-codes'
import { useQueueContext } from '../../../providers/Player/queue'
@@ -102,7 +102,7 @@ export default function Item({
<XStack justifyContent='space-between' alignItems='center' flex={2}>
{item.UserData?.IsFavorite ? (
<Icon small color={getTokens().color.telemagenta.val} name='heart' />
<Icon small color={'$primary'} name='heart' />
) : (
<Spacer />
)}

View File

@@ -1,7 +1,7 @@
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { ListItem, Separator, Spacer, XStack, YGroup, YStack } from 'tamagui'
import ItemImage from './image'
import Icon from '../helpers/icon'
import Icon from './icon'
import { H5, Text } from '../helpers/text'
interface ListGroupProps {

View File

@@ -4,7 +4,7 @@ import { getToken, getTokens, Theme, useTheme, XStack, YStack } from 'tamagui'
import { Text } from '../helpers/text'
import { RunTimeTicks } from '../helpers/time-codes'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import Icon from '../helpers/icon'
import Icon from './icon'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { StackParamList } from '../../../components/types'
import { QueuingType } from '../../../enums/queuing-type'
@@ -83,6 +83,7 @@ export default function Track({
<XStack
alignContent='center'
alignItems='center'
height={showArtwork ? '$6' : '$5'}
flex={1}
onPress={() => {
if (onPress) {
@@ -119,7 +120,6 @@ export default function Track({
justifyContent='center'
flex={showArtwork ? 2 : 1}
marginHorizontal={'$2'}
minHeight={showArtwork ? '$4' : 'unset'}
>
{showArtwork ? (
<FastImage
@@ -157,8 +157,13 @@ export default function Track({
{track.Name ?? 'Untitled Track'}
</Text>
{(showArtwork || (track.ArtistCount ?? 0 > 1)) && (
<Text lineBreakStrategyIOS='standard' numberOfLines={1}>
{(showArtwork || (track.Artists && track.Artists.length > 1)) && (
<Text
lineBreakStrategyIOS='standard'
numberOfLines={1}
bold
color={'$borderColor'}
>
{track.Artists?.join(', ') ?? ''}
</Text>
)}

View File

@@ -1,6 +1,6 @@
import React from 'react'
import { Square, Theme } from 'tamagui'
import Icon from './icon'
import Icon from '../components/icon'
import { TouchableOpacity } from 'react-native'
import { Text } from './text'
@@ -40,13 +40,7 @@ export default function IconButton({
justifyContent='center'
backgroundColor={'$background'}
>
<Icon
large={largeIcon}
small={!largeIcon}
name={name}
color={'$color'}
disabled={disabled}
/>
<Icon large={largeIcon} small={!largeIcon} name={name} disabled={disabled} />
{title && <Text textAlign='center'>{title}</Text>}
</Square>

View File

@@ -1,50 +0,0 @@
import { Card, getTokens, View } from 'tamagui'
import { H2, H4, H5 } from './text'
import Icon from './icon'
interface IconCardProps {
name: string
circular?: boolean | undefined
onPress: () => void
width?: number | undefined
caption?: string | undefined
largeIcon?: boolean | undefined
}
export default function IconCard({
name,
circular = false,
onPress,
width,
caption,
largeIcon,
}: IconCardProps): React.JSX.Element {
return (
<View alignItems='center' margin={5}>
<Card
animation='bouncy'
borderRadius={circular ? 300 : 5}
hoverStyle={{ scale: 0.925 }}
pressStyle={{ scale: 0.875 }}
width={width ? width : '$12'}
height={width ? width : '$12'}
onPress={onPress}
>
<Card.Header>
<H5 color={getTokens().color.purpleDark}>{caption ?? ''}</H5>
<Icon
color={getTokens().color.purpleDark.val}
name={name}
large={largeIcon}
small={!largeIcon}
/>
</Card.Header>
<Card.Footer padded></Card.Footer>
<Card.Background
backgroundColor={getTokens().color.telemagenta}
borderRadius={circular ? 300 : 5}
></Card.Background>
</Card>
</View>
)
}

View File

@@ -21,17 +21,13 @@ export function SwitchWithLabel(props: SwitchWithLabelProps) {
const id = `switch-${props.size.toString().slice(1)}-${props.checked ?? ''}}`
return (
<XStack alignItems='center' gap='$3'>
<Label size={props.size} htmlFor={id}>
{props.label}
</Label>
<Separator minHeight={20} vertical />
<Switch
id={id}
size={props.size}
checked={props.checked}
onCheckedChange={(checked: boolean) => props.onCheckedChange(checked)}
backgroundColor={
props.checked ? getToken('$color.telemagenta') : getToken('$color.purpleGray')
props.checked ? getToken('$color.success') : getToken('$color.purpleGray')
}
borderColor={
isDarkMode ? getToken('$color.amethyst') : getToken('$color.purpleDark')
@@ -39,6 +35,10 @@ export function SwitchWithLabel(props: SwitchWithLabelProps) {
>
<JellifySliderThumb animation='bouncy' />
</Switch>
<Separator minHeight={20} vertical />
<Label size={props.size} htmlFor={id}>
{props.label}
</Label>
</XStack>
)
}

View File

@@ -5,7 +5,7 @@ import React from 'react'
import { ItemCard } from '../../../components/Global/components/item-card'
import { View, XStack } from 'tamagui'
import { H2, H4, Text } from '../../../components/Global/helpers/text'
import Icon from '../../../components/Global/helpers/icon'
import Icon from '../../Global/components/icon'
import { useHomeContext } from '../../../providers/Home'
import { ActivityIndicator } from 'react-native'

View File

@@ -6,7 +6,7 @@ import HorizontalCardList from '../../../components/Global/components/horizontal
import { ItemCard } from '../../../components/Global/components/item-card'
import { QueuingType } from '../../../enums/queuing-type'
import { trigger } from 'react-native-haptic-feedback'
import Icon from '../../../components/Global/helpers/icon'
import Icon from '../../Global/components/icon'
import { useQueueContext } from '../../../providers/Player/queue'
import { usePlayerContext } from '../../../providers/Player'
import { H4 } from '../../../components/Global/helpers/text'

View File

@@ -6,7 +6,7 @@ import { StackParamList } from '../../types'
import { ItemCard } from '../../Global/components/item-card'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import HorizontalCardList from '../../../components/Global/components/horizontal-list'
import Icon from '../../../components/Global/helpers/icon'
import Icon from '../../Global/components/icon'
export default function RecentArtists({
navigation,

View File

@@ -9,7 +9,7 @@ import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { trigger } from 'react-native-haptic-feedback'
import { QueuingType } from '../../../enums/queuing-type'
import HorizontalCardList from '../../../components/Global/components/horizontal-list'
import Icon from '../../../components/Global/helpers/icon'
import Icon from '../../Global/components/icon'
import { useQueueContext } from '../../../providers/Player/queue'
export default function RecentlyPlayed({
@@ -41,7 +41,6 @@ export default function RecentlyPlayed({
</XStack>
<HorizontalCardList
squared
data={
(recentTracks?.pages.flatMap((page) => page).length ?? 0 > 10)
? recentTracks?.pages.flatMap((page) => page).slice(0, 10)

View File

@@ -3,9 +3,9 @@ import { RouteProp } from '@react-navigation/native'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs'
import PlaylistsScreen from './components/playlists-tab'
import { getToken } from 'tamagui'
import { useTheme } from 'tamagui'
import { useColorScheme } from 'react-native'
import Icon from '../Global/helpers/icon'
import Icon from '../Global/components/icon'
import TracksTab from './components/tracks-tab'
import ArtistsTab from './components/artists-tab'
import AlbumsTab from './components/albums-tab'
@@ -21,6 +21,7 @@ export default function Library({
navigation: NativeStackNavigationProp<StackParamList>
}): React.JSX.Element {
const isDarkMode = useColorScheme() === 'dark'
const theme = useTheme()
return (
<LibraryTabsNavigator.Navigator
@@ -28,10 +29,8 @@ export default function Library({
screenOptions={{
lazy: true,
tabBarShowIcon: true,
tabBarActiveTintColor: getToken('$color.telemagenta'),
tabBarInactiveTintColor: isDarkMode
? getToken('$color.amethyst')
: getToken('$color.purpleGray'),
tabBarActiveTintColor: theme.primary.val,
tabBarInactiveTintColor: theme.borderColor.val,
tabBarLabelStyle: {
fontFamily: 'Aileron-Bold',
},
@@ -42,7 +41,11 @@ export default function Library({
component={ArtistsTab}
options={{
tabBarIcon: ({ focused, color }) => (
<Icon name='microphone-variant' color={color} small />
<Icon
name='microphone-variant'
color={focused ? '$primary' : '$borderColor'}
small
/>
),
}}
/>
@@ -52,7 +55,11 @@ export default function Library({
component={AlbumsTab}
options={{
tabBarIcon: ({ focused, color }) => (
<Icon name='music-box-multiple' color={color} small />
<Icon
name='music-box-multiple'
color={focused ? '$primary' : '$borderColor'}
small
/>
),
}}
initialParams={{ navigation }}
@@ -63,7 +70,11 @@ export default function Library({
component={TracksTab}
options={{
tabBarIcon: ({ focused, color }) => (
<Icon name='music-clef-treble' color={color} small />
<Icon
name='music-clef-treble'
color={focused ? '$primary' : '$borderColor'}
small
/>
),
}}
/>
@@ -73,7 +84,11 @@ export default function Library({
component={PlaylistsScreen}
options={{
tabBarIcon: ({ focused, color }) => (
<Icon name='playlist-music' color={color} small />
<Icon
name='playlist-music'
color={focused ? '$primary' : '$borderColor'}
small
/>
),
}}
initialParams={{ navigation }}

View File

@@ -1,12 +1,11 @@
import { MaterialTopTabBar, MaterialTopTabBarProps } from '@react-navigation/material-top-tabs'
import React, { useEffect } from 'react'
import { Button, getToken, Separator, XStack, YStack } from 'tamagui'
import Icon from '../Global/helpers/icon'
import { Separator, XStack, YStack } from 'tamagui'
import Icon from '../Global/components/icon'
import { useLibrarySortAndFilterContext } from '../../providers/Library/sorting-filtering'
import { Text } from '../Global/helpers/text'
import { FadeIn, FadeOut } from 'react-native-reanimated'
import Animated from 'react-native-reanimated'
import { useLibraryContext } from '../../providers/Library'
export default function LibraryTabBar(props: MaterialTopTabBarProps) {
useEffect(() => {
@@ -40,12 +39,9 @@ export default function LibraryTabBar(props: MaterialTopTabBarProps) {
alignItems={'center'}
justifyContent={'center'}
>
<Icon
name={'plus-circle-outline'}
color={getToken('$color.telemagenta')}
/>
<Icon name={'plus-circle-outline'} color={'$primary'} />
<Text color={'$telemagenta'}>Create Playlist</Text>
<Text color={'$primary'}>Create Playlist</Text>
</XStack>
) : (
<XStack
@@ -56,20 +52,10 @@ export default function LibraryTabBar(props: MaterialTopTabBarProps) {
>
<Icon
name={isFavorites ? 'heart' : 'heart-outline'}
color={
isFavorites
? getToken('$color.telemagenta')
: getToken('$color.purpleGray')
}
color={isFavorites ? '$primary' : '$borderColor'}
/>
<Text
color={
isFavorites
? getToken('$color.telemagenta')
: getToken('$color.purpleGray')
}
>
<Text color={isFavorites ? '$primary' : '$borderColor'}>
{isFavorites ? 'Favorites' : 'All'}
</Text>
</XStack>
@@ -90,20 +76,10 @@ export default function LibraryTabBar(props: MaterialTopTabBarProps) {
? 'sort-alphabetical-descending'
: 'sort-alphabetical-ascending'
}
color={
sortDescending
? getToken('$color.success')
: getToken('$color.purpleGray')
}
color={sortDescending ? '$success' : '$borderColor'}
/>
<Text
color={
sortDescending
? getToken('$color.success')
: getToken('$color.purpleGray')
}
>
<Text color={sortDescending ? '$success' : '$borderColor'}>
{sortDescending ? 'Descending' : 'Ascending'}
</Text>
</XStack>

View File

@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react'
import _ from 'lodash'
import { useMutation } from '@tanstack/react-query'
import { JellifyServer } from '../../../types/JellifyServer'
import { Input, Spinner, YStack } from 'tamagui'
import { Input, ListItem, Separator, Spacer, Spinner, XStack, YGroup, YStack } from 'tamagui'
import { SwitchWithLabel } from '../../Global/helpers/switch-with-label'
import { H2 } from '../../Global/helpers/text'
import Button from '../../Global/helpers/button'
@@ -15,6 +15,8 @@ import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { StackParamList } from '../../../components/types'
import Toast from 'react-native-toast-message'
import { useJellifyContext } from '../../../providers'
import { useSettingsContext } from '../../../providers/Settings'
import Icon from '../../Global/components/icon'
export default function ServerAddress({
navigation,
@@ -26,6 +28,8 @@ export default function ServerAddress({
const { server, setServer, signOut } = useJellifyContext()
const { setSendMetrics, sendMetrics } = useSettingsContext()
useEffect(() => {
signOut()
}, [])
@@ -87,15 +91,7 @@ export default function ServerAddress({
</H2>
</YStack>
<YStack marginHorizontal={'$2'}>
<SwitchWithLabel
checked={useHttps}
onCheckedChange={(checked) => setUseHttps(checked)}
label='Use HTTPS'
size='$2'
width={100}
/>
<YStack marginHorizontal={'$4'} gap={'$4'}>
<Input
onChangeText={setServerAddress}
autoCapitalize='none'
@@ -103,6 +99,57 @@ export default function ServerAddress({
placeholder='jellyfin.org'
/>
<YGroup
gap={'$2'}
borderColor={'$borderColor'}
borderWidth={'$0.5'}
borderRadius={'$4'}
>
<YGroup.Item>
<ListItem
icon={
<Icon
name={useHttps ? 'lock-check' : 'lock-off'}
color={useHttps ? '$success' : '$borderColor'}
/>
}
title='HTTPS'
subTitle='Use HTTPS to connect to Jellyfin'
>
<SwitchWithLabel
checked={useHttps}
onCheckedChange={(checked) => setUseHttps(checked)}
label={useHttps ? 'Use HTTPS' : 'Use HTTP'}
size='$2'
width={100}
/>
</ListItem>
</YGroup.Item>
<Separator />
<YGroup.Item>
<ListItem
icon={
<Icon
name={sendMetrics ? 'bug-check' : 'bug'}
color={sendMetrics ? '$success' : '$borderColor'}
/>
}
title='Submit Usage and Crash Data'
subTitle='Send anonymized metrics and crash data'
>
<SwitchWithLabel
checked={sendMetrics}
onCheckedChange={(checked) => setSendMetrics(checked)}
label='Send Metrics'
size='$2'
width={100}
/>
</ListItem>
</YGroup.Item>
</YGroup>
{useServerMutation.isPending ? (
<Spinner />
) : (

View File

@@ -9,7 +9,7 @@ import { SafeAreaView } from 'react-native-safe-area-context'
import { JellifyUser } from '../../../types/JellifyUser'
import { StackParamList } from '../../../components/types'
import Input from '../../../components/Global/helpers/input'
import Icon from '../../../components/Global/helpers/icon'
import Icon from '../../Global/components/icon'
import { useJellifyContext } from '../../../providers'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import Toast from 'react-native-toast-message'
@@ -70,15 +70,9 @@ export default function ServerAuthentication({
{`Sign in to ${server?.name ?? 'Jellyfin'}`}
</H2>
</YStack>
<YStack marginHorizontal={'$2'}>
<YStack marginHorizontal={'$4'}>
<Input
prependElement={
<Icon
small
name='human-greeting-variant'
color={getToken('$color.amethyst')}
/>
}
prependElement={<Icon name='human-greeting-variant' color={'$borderColor'} />}
placeholder='Username'
value={username}
onChangeText={(value: string | undefined) => setUsername(value)}
@@ -89,9 +83,7 @@ export default function ServerAuthentication({
<Spacer />
<Input
prependElement={
<Icon small name='lock-outline' color={getToken('$color.amethyst')} />
}
prependElement={<Icon name='lock-outline' color={'$borderColor'} />}
placeholder='Password'
value={password}
onChangeText={(value: string | undefined) => setPassword(value)}

View File

@@ -41,10 +41,13 @@ export default function ServerLibrary({
}, [isPending, isSuccess])
return (
<SafeAreaView>
<YStack marginHorizontal={'$2'}>
<H2>Select Music Library</H2>
<SafeAreaView style={{ flex: 1 }}>
<YStack maxHeight={'$19'} flex={1} justifyContent='center'>
<H2 marginHorizontal={'$2'} textAlign='center'>
Select Music Library
</H2>
</YStack>
<YStack marginHorizontal={'$4'}>
{isPending ? (
<Spinner size='large' />
) : (

View File

@@ -1,5 +1,4 @@
import { State } from 'react-native-track-player'
import { Colors } from 'react-native/Libraries/NewAppScreen'
import { Circle, Spinner, View } from 'tamagui'
import { usePlayerContext } from '../../../providers/Player'
import IconButton from '../../../components/Global/helpers/icon-button'
@@ -31,7 +30,7 @@ export default function PlayPauseButton({
case State.Loading: {
button = (
<Circle size={size} disabled>
<Spinner marginHorizontal={10} size='small' color={Colors.Primary} />
<Spinner marginHorizontal={10} size='small' color={'$borderColor'} />
</Circle>
)
break

View File

@@ -1,7 +1,7 @@
import React from 'react'
import { XStack, getToken } from 'tamagui'
import PlayPauseButton from './buttons'
import Icon from '../../../components/Global/helpers/icon'
import Icon from '../../Global/components/icon'
import { usePlayerContext } from '../../../providers/Player'
import { useSafeAreaFrame } from 'react-native-safe-area-context'
import { useQueueContext } from '../../../providers/Player/queue'
@@ -15,14 +15,10 @@ export default function Controls(): React.JSX.Element {
return (
<XStack alignItems='center' justifyContent='space-evenly' marginVertical={'$4'}>
<Icon
color={getToken('$color.amethyst')}
name='rewind-15'
onPress={() => useSeekBy.mutate(-15)}
/>
<Icon color={'$borderColor'} name='rewind-15' onPress={() => useSeekBy.mutate(-15)} />
<Icon
color={getToken('$color.amethyst')}
color={'$borderColor'}
name='skip-previous'
onPress={() => usePrevious.mutate()}
large
@@ -32,14 +28,14 @@ export default function Controls(): React.JSX.Element {
<PlayPauseButton size={getToken('$13') - getToken('$5')} />
<Icon
color={getToken('$color.amethyst')}
color={'$borderColor'}
name='skip-next'
onPress={() => useSkip.mutate(undefined)}
large
/>
<Icon
color={getToken('$color.amethyst')}
color={'$borderColor'}
name='fast-forward-15'
onPress={() => useSeekBy.mutate(15)}
/>

View File

@@ -0,0 +1,35 @@
import { YStack } from 'tamagui'
import { XStack, Spacer } from 'tamagui'
import Icon from '../../Global/components/icon'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { StackParamList } from '../../../components/types'
export default function Footer({
navigation,
}: {
navigation: NativeStackNavigationProp<StackParamList>
}): React.JSX.Element {
return (
<YStack justifyContent='flex-end'>
<XStack justifyContent='space-evenly' marginVertical={'$3'}>
<Icon name='speaker-multiple' />
<Spacer />
<Spacer />
<Spacer />
<Icon
name='playlist-music'
onPress={() => {
navigation.navigate('Queue')
}}
/>
</XStack>
</YStack>
)
}

View File

@@ -5,7 +5,7 @@ import React, { useCallback, useMemo, useState } from 'react'
import { SafeAreaView, useSafeAreaFrame } from 'react-native-safe-area-context'
import { YStack, XStack, Spacer, getTokens, getToken, useTheme } from 'tamagui'
import { Text } from '../Global/helpers/text'
import Icon from '../Global/helpers/icon'
import Icon from '../Global/components/icon'
import FavoriteButton from '../Global/components/favorite-button'
import TextTicker from 'react-native-text-ticker'
import { TextTickerConfig } from './component.config'
@@ -19,6 +19,7 @@ import JellifyToastConfig from '../../constants/toast.config'
import { useColorScheme } from 'react-native'
import { useFocusEffect } from '@react-navigation/native'
import { useJellifyContext } from '../../providers'
import Footer from './helpers/footer'
export default function PlayerScreen({
navigation,
}: {
@@ -201,24 +202,7 @@ export default function PlayerScreen({
<Controls />
<YStack justifyContent='flex-end' height={'$10'} maxHeight={height / 10}>
<XStack justifyContent='space-evenly' marginVertical={'$3'}>
<Icon name='speaker-multiple' />
<Spacer />
<Spacer />
<Spacer />
<Icon
name='playlist-music'
onPress={() => {
navigation.navigate('Queue')
}}
/>
</XStack>
</YStack>
<Footer navigation={navigation} />
</YStack>
</>
)}

View File

@@ -3,7 +3,7 @@ import { getToken, getTokens, Image, useTheme, View, XStack, YStack } from 'tama
import { usePlayerContext } from '../../providers/Player'
import { BottomTabNavigationEventMap } from '@react-navigation/bottom-tabs'
import { NavigationHelpers, ParamListBase } from '@react-navigation/native'
import Icon from '../Global/helpers/icon'
import Icon from '../Global/components/icon'
import { Text } from '../Global/helpers/text'
import TextTicker from 'react-native-text-ticker'
import PlayPauseButton from './helpers/buttons'
@@ -79,7 +79,7 @@ export function Miniplayer({
<Icon
large
color={theme.borderColor.val}
color={'$borderColor'}
name='skip-next'
onPress={() => useSkip.mutate(undefined)}
/>

View File

@@ -1,4 +1,4 @@
import Icon from '../Global/helpers/icon'
import Icon from '../Global/components/icon'
import Track from '../Global/components/track'
import { StackParamList } from '../types'
import { usePlayerContext } from '../../providers/Player'

View File

@@ -5,7 +5,7 @@ import { useSafeAreaFrame } from 'react-native-safe-area-context'
import { getToken, getTokens, Separator, View, XStack, YStack } from 'tamagui'
import { AnimatedH5 } from '../../Global/helpers/text'
import InstantMixButton from '../../Global/components/instant-mix-button'
import Icon from '../../Global/helpers/icon'
import Icon from '../../Global/components/icon'
import { usePlaylistContext } from '../../../providers/Playlist'
import Animated, { useAnimatedStyle, withSpring } from 'react-native-reanimated'
import FastImage from 'react-native-fast-image'
@@ -136,7 +136,7 @@ function PlaylistHeaderControls({
<YStack justifyContent='center' alignContent='center'>
{editing ? (
<Icon
color={getToken('$color.danger')}
color={'$danger'}
name='delete-sweep-outline' // otherwise use "delete-circle"
onPress={() => navigation.navigate('DeletePlaylist', { playlist })}
/>
@@ -147,7 +147,7 @@ function PlaylistHeaderControls({
<YStack justifyContent='center' alignContent='center'>
<Icon
color={getToken('$color.amethyst')}
color={'$borderColor'}
name={editing ? 'content-save-outline' : 'pencil'}
onPress={() => setEditing(!editing)}
/>

View File

@@ -1,6 +1,6 @@
import { Separator, XStack } from 'tamagui'
import Track from '../Global/components/track'
import Icon from '../Global/helpers/icon'
import Icon from '../Global/components/icon'
import { trigger } from 'react-native-haptic-feedback'
import { RefreshControl } from 'react-native'
import { PlaylistProps } from './interfaces'

View File

@@ -1,6 +1,6 @@
import { FlatList, RefreshControl } from 'react-native-gesture-handler'
import { ItemCard } from '../Global/components/item-card'
import Icon from '../Global/helpers/icon'
import Icon from '../Global/components/icon'
import { getToken, getTokens } from 'tamagui'
import { fetchFavoritePlaylists } from '../../api/queries/favorites'
import { QueryKeys } from '../../enums/query-keys'

View File

@@ -1,15 +0,0 @@
import { XStack } from '@tamagui/stacks'
import React from 'react'
import { Text } from '../../components/Global/helpers/text'
import Icon from '../../components/Global/helpers/icon'
import { useJellifyContext } from '../../providers'
export default function AccountDetails(): React.JSX.Element {
const { user } = useJellifyContext()
return (
<XStack alignItems='center'>
<Icon name='account-music-outline' />
<Text>{user!.name}</Text>
</XStack>
)
}

View File

@@ -1,19 +0,0 @@
import { QueryKeys } from '../../enums/query-keys'
interface CategoryRoute {
/* eslint-disable @typescript-eslint/no-explicit-any */
name: any // ¯\_(ツ)_/¯
iconName: string
params?: {
query: QueryKeys
}
}
const Categories: CategoryRoute[] = [
{ name: 'Account', iconName: 'account-key-outline' },
{ name: 'Server', iconName: 'server-network' },
{ name: 'Playback', iconName: 'disc-player' },
{ name: 'Labs', iconName: 'flask-outline' },
]
export default Categories

View File

@@ -1,42 +1,80 @@
import React from 'react'
import SignOut from '../../components/Settings/sign-out'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { StackParamList } from '../types'
import { useSafeAreaFrame } from 'react-native-safe-area-context'
import { FlatList } from 'react-native'
import IconCard from '../Global/helpers/icon-card'
import Categories from '../../components/Settings/categories'
import StorageBar from '../Storage'
import { useColorScheme } from 'react-native'
import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs'
import { getToken, useTheme } from 'tamagui'
import AccountTab from './components/account-tab'
import Icon from '../Global/components/icon'
import LabsTab from './components/labs-tab'
import PreferencesTab from './components/preferences-tab'
import InfoTab from './components/info-tab'
export default function Root({
navigation,
}: {
navigation: NativeStackNavigationProp<StackParamList>
}): React.JSX.Element {
const { width } = useSafeAreaFrame()
const SettingsTabsNavigator = createMaterialTopTabNavigator()
export default function Settings(): React.JSX.Element {
const theme = useTheme()
return (
<FlatList
contentInsetAdjustmentBehavior='automatic'
data={Categories}
numColumns={2}
renderItem={({ index, item }) => (
<IconCard
name={item.iconName}
caption={item.name}
width={width / 2.1}
onPress={() => {
navigation.navigate(item.name, item.params)
}}
largeIcon
/>
)}
ListFooterComponent={
<>
<StorageBar />
<SignOut />
</>
}
/>
<SettingsTabsNavigator.Navigator
screenOptions={{
tabBarShowIcon: true,
tabBarActiveTintColor: theme.primary.val,
tabBarInactiveTintColor: theme.borderColor.val,
tabBarLabelStyle: {
fontFamily: 'Aileron-Bold',
},
}}
>
<SettingsTabsNavigator.Screen
name='Settings'
component={PreferencesTab}
options={{
tabBarIcon: ({ focused, color }) => (
<Icon
name='headphones-settings'
color={focused ? '$primary' : '$borderColor'}
small
/>
),
}}
/>
<SettingsTabsNavigator.Screen
name='Account'
component={AccountTab}
options={{
tabBarIcon: ({ focused, color }) => (
<Icon
name='account-music'
color={focused ? '$primary' : '$borderColor'}
small
/>
),
}}
/>
<SettingsTabsNavigator.Screen
name='Labs'
component={LabsTab}
options={{
tabBarIcon: ({ focused, color }) => (
<Icon name='flask' color={focused ? '$primary' : '$borderColor'} small />
),
}}
/>
<SettingsTabsNavigator.Screen
name='About'
component={InfoTab}
options={{
tabBarIcon: ({ focused, color }) => (
<Icon
name='information'
color={focused ? '$primary' : '$borderColor'}
small
/>
),
}}
/>
</SettingsTabsNavigator.Navigator>
)
}

View File

@@ -0,0 +1,47 @@
import React from 'react'
import Icon from '../../Global/components/icon'
import { useJellifyContext } from '../../../providers'
import { SafeAreaView } from 'react-native-safe-area-context'
import SignOut from './sign-out-button'
import { SettingsStackParamList } from '../../../screens/Settings/types'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { useNavigation } from '@react-navigation/native'
import { Text } from '../../Global/helpers/text'
import SettingsListGroup from './settings-list-group'
export default function AccountTab(): React.JSX.Element {
const { user, library, server } = useJellifyContext()
const navigation = useNavigation<NativeStackNavigationProp<SettingsStackParamList>>()
return (
<SafeAreaView>
<SettingsListGroup
settingsList={[
{
title: 'Username',
subTitle: "You're awesome!",
iconName: 'account-music',
iconColor: '$borderColor',
children: <Text>{user?.name ?? 'Unknown User'}</Text>,
},
{
title: 'Selected Library',
subTitle: '',
iconName: 'book-music',
iconColor: '$borderColor',
children: <Text>{library?.musicLibraryName ?? 'Unknown Library'}</Text>,
},
{
title: 'Jellyfin Server',
subTitle: server?.version ?? 'Unknown Jellyfin Version',
iconName: 'server',
iconColor: '$borderColor',
children: <Text>{server?.name ?? 'Unknown Server'}</Text>,
},
]}
/>
<SignOut navigation={navigation} />
</SafeAreaView>
)
}

View File

@@ -0,0 +1,34 @@
import { SafeAreaView } from 'react-native-safe-area-context'
import { getToken, ListItem, Progress, Separator, YGroup } from 'tamagui'
import Icon from '../../Global/components/icon'
import { version } from '../../../../package.json'
import { Text } from '../../Global/helpers/text'
import { useNetworkContext } from '../../../providers/Network'
import SettingsListGroup from './settings-list-group'
export default function InfoTab() {
const { downloadedTracks, storageUsage } = useNetworkContext()
return (
<SafeAreaView>
<SettingsListGroup
settingsList={[
{
title: 'Storage',
subTitle: `${downloadedTracks?.length ?? '0'} ${
downloadedTracks?.length === 1 ? 'song' : 'songs'
} in your pocket`,
iconName: 'harddisk',
iconColor: '$borderColor',
},
{
title: 'Jellify',
subTitle: 'Made with 💜 by Violet Caulfield',
iconName: 'jellyfish',
iconColor: '$borderColor',
},
]}
/>
</SafeAreaView>
)
}

View File

@@ -0,0 +1,21 @@
import { ListItem, View, YGroup } from 'tamagui'
import { Text } from '../../Global/helpers/text'
import { SwitchWithLabel } from '../../Global/helpers/switch-with-label'
import Icon from '../../Global/components/icon'
import SettingsListGroup from './settings-list-group'
export default function LabsTab(): React.JSX.Element {
return (
<SettingsListGroup
borderColor={'$danger'}
settingsList={[
{
title: 'Nothing to see here...(yet)',
subTitle: 'Come back later to enable beta features',
iconName: 'test-tube-off',
iconColor: '$danger',
},
]}
/>
)
}

View File

@@ -0,0 +1,31 @@
import { getToken } from 'tamagui'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useSettingsContext } from '../../../providers/Settings'
import { SwitchWithLabel } from '../../Global/helpers/switch-with-label'
import SettingsListGroup from './settings-list-group'
export default function PreferencesTab(): React.JSX.Element {
const { setSendMetrics, sendMetrics } = useSettingsContext()
return (
<SafeAreaView>
<SettingsListGroup
settingsList={[
{
title: 'Send Metrics and Crash Reports',
iconName: sendMetrics ? 'bug-check' : 'bug',
iconColor: sendMetrics ? '$success' : '$borderColor',
subTitle: 'Send anonymous usage and crash data',
children: (
<SwitchWithLabel
checked={sendMetrics}
onCheckedChange={setSendMetrics}
size={'$2'}
label={sendMetrics ? 'Enabled' : 'Disabled'}
/>
),
},
]}
/>
</SafeAreaView>
)
}

View File

@@ -0,0 +1,42 @@
import { ListItem, Separator, YGroup } from 'tamagui'
import { SettingsTabList } from '../types'
import Icon from '../../Global/components/icon'
import { ThemeTokens } from 'tamagui'
import React from 'react'
interface SettingsListGroupProps {
settingsList: SettingsTabList
borderColor?: ThemeTokens
}
export default function SettingsListGroup({
settingsList,
borderColor,
}: SettingsListGroupProps): React.JSX.Element {
return (
<YGroup
alignSelf='center'
borderColor={borderColor ?? '$borderColor'}
borderWidth={'$1'}
borderRadius={'$4'}
margin={'$4'}
>
{settingsList.map((setting, index, self) => (
<>
<YGroup.Item key={setting.title}>
<ListItem
size={'$5'}
title={setting.title}
icon={<Icon name={setting.iconName} color={setting.iconColor} />}
subTitle={setting.subTitle}
>
{setting.children}
</ListItem>
</YGroup.Item>
{index !== self.length - 1 && <Separator />}
</>
))}
</YGroup>
)
}

View File

@@ -0,0 +1,26 @@
import React from 'react'
import Button from '../../Global/helpers/button'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { SettingsStackParamList } from '../../../screens/Settings/types'
import { Text } from '../../Global/helpers/text'
export default function SignOut({
navigation,
}: {
navigation: NativeStackNavigationProp<SettingsStackParamList>
}): React.JSX.Element {
return (
<Button
color={'$danger'}
borderColor={'$danger'}
marginHorizontal={'$6'}
onPress={() => {
navigation.navigate('SignOut')
}}
>
<Text bold color={'$danger'}>
Sign Out
</Text>
</Button>
)
}

View File

@@ -1,7 +0,0 @@
import { ScrollView } from 'tamagui'
export default function DevTools(): React.JSX.Element {
return (
<ScrollView contentInsetAdjustmentBehavior='automatic' removeClippedSubviews></ScrollView>
)
}

View File

@@ -1,14 +0,0 @@
import { Text } from '../../components/Global/helpers/text'
import React from 'react'
import { View } from 'tamagui'
import { useJellifyContext } from '../../providers'
export default function LibraryDetails(): React.JSX.Element {
const { library } = useJellifyContext()
return (
<View>
<Text>{`LibraryID: ${library!.musicLibraryId}`}</Text>
<Text>{`Playlist LibraryID: ${library!.playlistLibraryId}`}</Text>
</View>
)
}

View File

@@ -1,39 +0,0 @@
import React from 'react'
import Button from '../Global/helpers/button'
import TrackPlayer from 'react-native-track-player'
import { StackParamList } from '../types'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { useNavigation } from '@react-navigation/native'
export default function SignOut(): React.JSX.Element {
const navigation = useNavigation<NativeStackNavigationProp<StackParamList>>()
return (
<Button
onPress={() => {
TrackPlayer.reset()
.then(() => {
console.debug('TrackPlayer cleared')
})
.catch((error) => {
console.error('Error clearing TrackPlayer', error)
})
.finally(() => {
navigation.reset({
index: 0,
routes: [
{
name: 'Login',
params: {
screen: 'ServerAddress',
},
},
],
})
})
}}
>
Sign Out
</Button>
)
}

7
src/components/Settings/types.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
export type SettingsTabList = {
title: string
iconName: string
iconColor: ThemeTokens
subTitle: string
children?: React.ReactNode
}[]

View File

@@ -4,7 +4,7 @@ import RNFS from 'react-native-fs'
import Animated, { useSharedValue, useAnimatedStyle, withTiming } from 'react-native-reanimated'
import { deleteAudioCache } from '../../components/Network/offlineModeUtils'
import { useNetworkContext } from '../../providers/Network'
import Icon from '../Global/helpers/icon'
import Icon from '../Global/components/icon'
import { getToken, View } from 'tamagui'
import { Text } from '../Global/helpers/text'

View File

@@ -7,6 +7,11 @@ import { JellifyUserDataProvider } from '../providers/UserData'
import { NetworkContextProvider } from '../providers/Network'
import { QueueProvider } from '../providers/Player/queue'
import { DisplayProvider } from '../providers/Display/display-provider'
import { SettingsProvider, useSettingsContext } from '../providers/Settings'
import { createTelemetryDeck, TelemetryDeckProvider } from '@typedigital/telemetrydeck-react'
import telemetryDeckConfig from '../../telemetrydeck.json'
import glitchtipConfig from '../../glitchtip.json'
import * as Sentry from '@sentry/react-native'
/**
* The main component for the Jellify app. Children are wrapped in the {@link JellifyProvider}
@@ -14,13 +19,38 @@ import { DisplayProvider } from '../providers/Display/display-provider'
*/
export default function Jellify(): React.JSX.Element {
return (
<DisplayProvider>
<JellifyProvider>
<App />
</JellifyProvider>
</DisplayProvider>
<SettingsProvider>
<JellifyLoggingWrapper>
<DisplayProvider>
<JellifyProvider>
<App />
</JellifyProvider>
</DisplayProvider>
</JellifyLoggingWrapper>
</SettingsProvider>
)
}
function JellifyLoggingWrapper({ children }: { children: React.ReactNode }): React.JSX.Element {
const { sendMetrics } = useSettingsContext()
let telemetrydeck = undefined
if (sendMetrics) {
telemetrydeck = createTelemetryDeck(telemetryDeckConfig)
}
Sentry.init({
...glitchtipConfig,
enabled: sendMetrics,
})
return sendMetrics && telemetrydeck ? (
<TelemetryDeckProvider telemetryDeck={telemetrydeck}>{children}</TelemetryDeckProvider>
) : (
<>{children}</>
)
}
/**
* The main component for the Jellify app. Depends on {@link useJellifyContext} hook to determine if the user is logged in
* @returns The {@link App} component

View File

@@ -5,7 +5,7 @@ import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityI
import SettingsScreen from '../screens/Settings'
import { Discover } from '../screens/Discover'
import { Miniplayer } from './Player/mini-player'
import { getToken, getTokens, Separator } from 'tamagui'
import { getToken, getTokens, Separator, useTheme } from 'tamagui'
import { usePlayerContext } from '../providers/Player'
import SearchStack from '../screens/Search'
import LibraryStack from '../screens/Library'
@@ -15,7 +15,7 @@ import InternetConnectionWatcher from './Network/internetConnectionWatcher'
const Tab = createBottomTabNavigator()
export function Tabs(): React.JSX.Element {
const isDarkMode = useColorScheme() === 'dark'
const theme = useTheme()
const { nowPlaying } = usePlayerContext()
return (
@@ -23,13 +23,8 @@ export function Tabs(): React.JSX.Element {
initialRouteName='Home'
screenOptions={{
animation: 'shift',
tabBarActiveTintColor: getTokens().color.telemagenta.val,
tabBarInactiveTintColor: isDarkMode
? getToken('$color.amethyst')
: getToken('$color.purpleGray'),
tabBarLabelStyle: {
fontWeight: 'bold',
},
tabBarActiveTintColor: theme.primary.val,
tabBarInactiveTintColor: theme.borderColor.val,
}}
tabBar={(props) => (
<>

View File

@@ -73,6 +73,7 @@ export type StackParamList = {
Server: undefined
Playback: undefined
Labs: undefined
SignOut: undefined
Tabs: {
screen: keyof StackParamList

View File

@@ -12,4 +12,6 @@ export enum MMKVStorageKeys {
Api = 'Api',
LibrarySortDescending = 'LibrarySortDescending',
LibraryIsFavorites = 'LibraryIsFavorites',
SendMetrics = 'SEND_METRICS',
AutoDownload = 'AutoDownload',
}

View File

@@ -78,4 +78,5 @@ export enum QueryKeys {
AllArtists = 'AllArtists',
AllTracks = 'AllTracks',
AllAlbums = 'AllAlbums',
StorageInUse = 'StorageInUse',
}

View File

@@ -1,5 +1,3 @@
import { SharedValue } from 'react-native-reanimated'
/**
* Converts the run time seconds of a track to the RunTimeTicks standard set by Emby / Jellyfin
* @param seconds The run time seconds of the item to convert to Jellyfin ticks

View File

@@ -47,7 +47,8 @@ export async function handlePlaybackProgress(
track: JellifyTrack,
progress: Progress,
) {
if (Math.floor(progress.duration - progress.position) === 5) {
console.debug('Playback progress updated')
if (Math.floor(progress.duration) - Math.floor(progress.position) <= 9) {
console.debug(`Track finished. ${playstateApi ? 'scrobbling...' : ''}`)
if (playstateApi)

View File

@@ -1,6 +1,12 @@
import React, { createContext, ReactNode, useContext } from 'react'
import { JellifyDownload } from '../../types/JellifyDownload'
import { useMutation, UseMutationResult, useQuery, useQueryClient } from '@tanstack/react-query'
import {
useMutation,
UseMutationResult,
useQuery,
useQueryClient,
UseQueryResult,
} from '@tanstack/react-query'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { mapDtoToTrack } from '../../helpers/mappings'
import { deleteAudio, getAudioCache, saveAudio } from '../../components/Network/offlineModeUtils'
@@ -9,9 +15,13 @@ import { networkStatusTypes } from '../../components/Network/internetConnectionW
import DownloadProgress from '../../types/DownloadProgress'
import { useJellifyContext } from '..'
import { isUndefined } from 'lodash'
import RNFS from 'react-native-fs'
import { JellifyStorage } from './types'
interface NetworkContext {
useDownload: UseMutationResult<void, Error, BaseItemDto, unknown>
useRemoveDownload: UseMutationResult<void, Error, BaseItemDto, unknown>
storageUsage: JellifyStorage | undefined
downloadedTracks: JellifyDownload[] | undefined
activeDownloads: DownloadProgress[] | undefined
networkStatus: networkStatusTypes | undefined
@@ -21,6 +31,17 @@ const NetworkContextInitializer = () => {
const { api, sessionId } = useJellifyContext()
const queryClient = useQueryClient()
const fetchStorageInUse: () => Promise<JellifyStorage> = async () => {
const totalStorage = await RNFS.getFSInfo()
const storageInUse = await RNFS.stat(RNFS.DocumentDirectoryPath)
return {
totalStorage: totalStorage.totalSpace,
freeSpace: totalStorage.freeSpace,
storageInUseByJellify: storageInUse.size,
}
}
const useDownload = useMutation({
mutationFn: (trackItem: BaseItemDto) => {
if (isUndefined(api)) throw new Error('API client not initialized')
@@ -37,6 +58,12 @@ const NetworkContextInitializer = () => {
},
})
const { data: storageUsage } = useQuery({
queryKey: [QueryKeys.StorageInUse],
queryFn: () => fetchStorageInUse(),
staleTime: 1000 * 60 * 60 * 1, // 1 hour
})
const useRemoveDownload = useMutation({
mutationFn: (trackItem: BaseItemDto) => deleteAudio(trackItem),
onSuccess: (data, { Id }) => {
@@ -67,6 +94,7 @@ const NetworkContextInitializer = () => {
activeDownloads,
downloadedTracks,
networkStatus,
storageUsage,
}
}
@@ -110,6 +138,7 @@ const NetworkContext = createContext<NetworkContext>({
downloadedTracks: [],
activeDownloads: [],
networkStatus: networkStatusTypes.ONLINE,
storageUsage: undefined,
})
export const NetworkContextProvider: ({
@@ -117,22 +146,9 @@ export const NetworkContextProvider: ({
}: {
children: ReactNode
}) => React.JSX.Element = ({ children }: { children: ReactNode }) => {
const { useDownload, useRemoveDownload, downloadedTracks, activeDownloads, networkStatus } =
NetworkContextInitializer()
const context = NetworkContextInitializer()
return (
<NetworkContext.Provider
value={{
useDownload,
useRemoveDownload,
activeDownloads,
downloadedTracks,
networkStatus,
}}
>
{children}
</NetworkContext.Provider>
)
return <NetworkContext.Provider value={context}>{children}</NetworkContext.Provider>
}
export const useNetworkContext = () => useContext(NetworkContext)

5
src/providers/Network/types.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
export type JellifyStorage = {
totalStorage: number
freeSpace: number
storageInUseByJellify: number
}

View File

@@ -69,6 +69,8 @@ const PlayerContextInitializer = () => {
const handlePlaybackProgressUpdated = async (progress: Progress) => {
if (playStateApi && nowPlaying)
await handlePlaybackProgress(sessionId, playStateApi, nowPlaying, progress)
else if (!playStateApi) console.warn('No play state API found')
else console.warn('No now playing track found')
}
//#endregion Functions
@@ -147,6 +149,7 @@ const PlayerContextInitializer = () => {
break
}
case Event.PlaybackProgressUpdated: {
console.debug('Playback progress updated')
usePlaybackProgressUpdated.mutate(event)
// Cache playing track at 20 seconds if it's not already downloaded

View File

@@ -0,0 +1,50 @@
import { storage } from '../../constants/storage'
import { MMKVStorageKeys } from '../../enums/mmkv-storage-keys'
import { createContext, useContext, useEffect, useState } from 'react'
interface SettingsContext {
sendMetrics: boolean
setSendMetrics: React.Dispatch<React.SetStateAction<boolean>>
autoDownload: boolean
setAutoDownload: React.Dispatch<React.SetStateAction<boolean>>
}
const SettingsContextInitializer = () => {
const sendMetricsInit = storage.getBoolean(MMKVStorageKeys.SendMetrics)
const autoDownloadInit = storage.getBoolean(MMKVStorageKeys.AutoDownload)
const [sendMetrics, setSendMetrics] = useState(sendMetricsInit ?? false)
const [autoDownload, setAutoDownload] = useState(autoDownloadInit ?? false)
useEffect(() => {
storage.set(MMKVStorageKeys.SendMetrics, sendMetrics)
}, [sendMetrics])
useEffect(() => {
storage.set(MMKVStorageKeys.AutoDownload, autoDownload)
}, [autoDownload])
return {
sendMetrics,
setSendMetrics,
autoDownload,
setAutoDownload,
}
}
export const SettingsContext = createContext<SettingsContext>({
sendMetrics: false,
setSendMetrics: () => {},
autoDownload: false,
setAutoDownload: () => {},
})
export const SettingsProvider = ({ children }: { children: React.ReactNode }) => {
const context = SettingsContextInitializer()
return <SettingsContext.Provider value={context}>{children}</SettingsContext.Provider>
}
export const useSettingsContext = () => useContext(SettingsContext)

View File

@@ -21,8 +21,7 @@ export function Discover(): React.JSX.Element {
name='Discover'
component={Index}
options={{
headerLargeTitle: true,
headerLargeTitleStyle: {
headerTitleStyle: {
fontFamily: 'Aileron-Bold',
},
}}

View File

@@ -26,8 +26,15 @@ export default function Home(): React.JSX.Element {
<HomeProvider>
<HomeStack.Navigator initialRouteName='Home' screenOptions={{ headerShown: true }}>
<HomeStack.Group>
<HomeStack.Screen name='Home' component={ProvidedHome} />
<HomeStack.Screen
name='Home'
component={ProvidedHome}
options={{
headerTitleStyle: {
fontFamily: 'Aileron-Bold',
},
}}
/>
<HomeStack.Screen
name='Artist'
component={ArtistScreen}
@@ -35,6 +42,7 @@ export default function Home(): React.JSX.Element {
title: route.params.artist.Name ?? 'Unknown Artist',
headerTitleStyle: {
color: theme.background.val,
fontFamily: 'Aileron-Bold',
},
})}
/>

View File

@@ -1,17 +1,17 @@
import { Label } from '../../Global/helpers/text'
import Input from '../../Global/helpers/input'
import { Label } from '../../components/Global/helpers/text'
import Input from '../../components/Global/helpers/input'
import React, { useState } from 'react'
import { View, XStack } from 'tamagui'
import Button from '../../Global/helpers/button'
import Button from '../../components/Global/helpers/button'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { StackParamList } from '../../types'
import { StackParamList } from '../../components/types'
import { useMutation } from '@tanstack/react-query'
import { createPlaylist } from '../../../api/mutations/playlists'
import { createPlaylist } from '../../api/mutations/playlists'
import { trigger } from 'react-native-haptic-feedback'
import { queryClient } from '../../../constants/query-client'
import { QueryKeys } from '../../../enums/query-keys'
import { queryClient } from '../../constants/query-client'
import { QueryKeys } from '../../enums/query-keys'
import Toast from 'react-native-toast-message'
import { useJellifyContext } from '../../../providers'
import { useJellifyContext } from '../../providers'
// import * as Burnt from 'burnt'
export default function AddPlaylist({

View File

@@ -1,14 +1,14 @@
import { View, XStack } from 'tamagui'
import { DeletePlaylistProps } from '../../../components/types'
import Button from '../../../components/Global/helpers/button'
import { Text } from '../../../components/Global/helpers/text'
import { DeletePlaylistProps } from '../../components/types'
import Button from '../../components/Global/helpers/button'
import { Text } from '../../components/Global/helpers/text'
import { useMutation } from '@tanstack/react-query'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { deletePlaylist } from '../../../api/mutations/playlists'
import { deletePlaylist } from '../../api/mutations/playlists'
import { trigger } from 'react-native-haptic-feedback'
import { queryClient } from '../../../constants/query-client'
import { QueryKeys } from '../../../enums/query-keys'
import { useJellifyContext } from '../../../providers'
import { queryClient } from '../../constants/query-client'
import { QueryKeys } from '../../enums/query-keys'
import { useJellifyContext } from '../../providers'
// import * as Burnt from 'burnt'
export default function DeletePlaylist({

View File

@@ -5,8 +5,8 @@ import Library from '../../components/Library/component'
import { AlbumScreen } from '../../components/Album'
import { PlaylistScreen } from '../Playlist'
import DetailsScreen from '../Detail'
import AddPlaylist from '../../components/Library/components/add-playlist'
import DeletePlaylist from '../../components/Library/components/delete-playlist'
import AddPlaylist from './add-playlist'
import DeletePlaylist from './delete-playlist'
import { ArtistScreen } from '../Artist'
import InstantMix from '../../components/InstantMix/component'
import { useTheme } from 'tamagui'

View File

@@ -1,11 +1,11 @@
import { StackParamList } from '../../components/types'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import AccountDetails from '../../components/Settings/account-details'
import AccountTab from '../../components/Settings/components/account-tab'
export default function AccountDetailsScreen({
navigation,
}: {
navigation: NativeStackNavigationProp<StackParamList>
}): React.JSX.Element {
return <AccountDetails />
return <AccountTab />
}

View File

@@ -1,65 +1,31 @@
import React from 'react'
import { createNativeStackNavigator } from '@react-navigation/native-stack'
import Root from '../../components/Settings/component'
import AccountDetails from './account-details'
import Labs from './labs'
import DetailsScreen from '../Detail'
import { StackParamList } from '../../components/types'
import PlaybackDetails from './playback-details'
import ServerDetails from './server-details'
import Settings from '../../components/Settings/component'
import SignOutModal from './sign-out-modal'
import { SettingsStackParamList } from './types'
export const SettingsStack = createNativeStackNavigator<StackParamList>()
export const SettingsStack = createNativeStackNavigator<SettingsStackParamList>()
export default function SettingsScreen(): React.JSX.Element {
return (
<SettingsStack.Navigator>
<SettingsStack.Screen
name='Settings'
component={Root}
options={{
headerLargeTitle: true,
headerLargeTitleStyle: {
fontFamily: 'Aileron-Bold',
},
}}
/>
<SettingsStack.Navigator
initialRouteName='Settings'
screenOptions={{
headerShown: false,
}}
>
<SettingsStack.Screen name='Settings' component={Settings} />
<SettingsStack.Screen
name='Account'
component={AccountDetails}
name='SignOut'
component={SignOutModal}
options={{
title: 'Account',
headerLargeTitle: true,
headerLargeTitleStyle: {
fontFamily: 'Aileron-Bold',
},
/* https://www.reddit.com/r/reactnative/comments/1dgktbn/comment/lxd23sj/?utm_source=share&utm_medium=web3x&utm_name=web3xcss&utm_term=1&utm_content=share_button */
presentation: 'formSheet',
sheetInitialDetentIndex: 0,
sheetAllowedDetents: [0.2],
}}
/>
<SettingsStack.Screen name='Server' component={ServerDetails} />
<SettingsStack.Screen name='Playback' component={PlaybackDetails} />
<SettingsStack.Screen
name='Labs'
component={Labs}
options={{
headerLargeTitle: true,
headerLargeTitleStyle: {
fontFamily: 'Aileron-Bold',
},
}}
/>
<SettingsStack.Group screenOptions={{ presentation: 'modal' }}>
<SettingsStack.Screen
name='Details'
component={DetailsScreen}
options={{
headerShown: false,
}}
/>
</SettingsStack.Group>
</SettingsStack.Navigator>
)
}

View File

@@ -1,11 +0,0 @@
import { StackParamList } from '../../components/types'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import DevTools from '../../components/Settings/dev-tools'
export default function Labs({
navigation,
}: {
navigation: NativeStackNavigationProp<StackParamList>
}): React.JSX.Element {
return <DevTools />
}

View File

@@ -1,5 +0,0 @@
import { View } from 'tamagui'
export default function PlaybackDetails(): React.JSX.Element {
return <View />
}

View File

@@ -1,35 +0,0 @@
import React from 'react'
import { YStack, XStack } from 'tamagui'
import { H5, Text } from '../../components/Global/helpers/text'
import Icon from '../../components/Global/helpers/icon'
import { useJellifyContext } from '../../providers'
export default function ServerDetails(): React.JSX.Element {
const { api, library } = useJellifyContext()
return (
<YStack>
{api && (
<YStack>
<H5>Access Token</H5>
<XStack>
<Icon name='hand-coin-outline' />
<Text>{api.accessToken}</Text>
</XStack>
<H5>Jellyfin Server</H5>
<XStack>
<Icon name='server-network' />
<Text>{api.basePath}</Text>
</XStack>
</YStack>
)}
{library && (
<YStack>
<H5>Library</H5>
<XStack>
<Icon name='book-outline' />
<Text>{library.musicLibraryName!}</Text>
</XStack>
</YStack>
)}
</YStack>
)
}

View File

@@ -0,0 +1,58 @@
import TrackPlayer from 'react-native-track-player'
import { Button, Spacer, View, XStack, YStack } from 'tamagui'
import { SignOutModalProps } from './types'
import { H5, Text } from '../../components/Global/helpers/text'
export default function SignOutModal({ navigation }: SignOutModalProps): React.JSX.Element {
return (
<YStack marginHorizontal={'$6'}>
<H5>Sign out?</H5>
<Spacer />
<XStack gap={'$2'}>
<Button
borderWidth={'$1'}
borderColor={'$borderColor'}
flex={1}
onPress={() => {
navigation.goBack()
}}
>
<Text bold color={'$borderColor'}>
Cancel
</Text>
</Button>
<Button
flex={1}
color={'$danger'}
borderColor={'$danger'}
onPress={() => {
TrackPlayer.reset()
.then(() => {
console.debug('TrackPlayer cleared')
})
.catch((error) => {
console.error('Error clearing TrackPlayer', error)
})
.finally(() => {
navigation.reset({
index: 0,
routes: [
{
name: 'Login',
params: {
screen: 'ServerAddress',
},
},
],
})
})
}}
>
<Text bold color={'$danger'}>
Sign out
</Text>
</Button>
</XStack>
</YStack>
)
}

7
src/screens/Settings/types.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
export type SettingsStackParamList = {
Settings: undefined
SignOut: undefined
}
export type SettingsProps = NativeStackScreenProps<SettingsStackParamList, 'Settings'>
export type SignOutModalProps = NativeStackScreenProps<SettingsStackParamList, 'SignOut'>

View File

@@ -37,22 +37,34 @@ const jellifyConfig = createTamagui({
backgroundHover: tokens.color.purpleGray,
borderColor: tokens.color.amethyst,
color: tokens.color.white,
success: tokens.color.success,
primary: tokens.color.telemagenta,
danger: tokens.color.danger,
},
dark_inverted_purple: {
color: tokens.color.purpleDark,
borderColor: tokens.color.amethyst,
background: tokens.color.amethyst,
success: tokens.color.success,
primary: tokens.color.telemagenta,
danger: tokens.color.danger,
},
light: {
background: tokens.color.white,
backgroundActive: tokens.color.amethyst,
borderColor: tokens.color.purpleGray,
color: tokens.color.purpleDark,
success: tokens.color.success,
primary: tokens.color.telemagenta,
danger: tokens.color.danger,
},
light_inverted_purple: {
color: tokens.color.purpleDark,
borderColor: tokens.color.purpleDark,
background: tokens.color.purpleGray,
success: tokens.color.success,
primary: tokens.color.telemagenta,
danger: tokens.color.danger,
},
},
})

5
telemetrydeck.json Normal file
View File

@@ -0,0 +1,5 @@
{
"app": "Jellify",
"appID": "00000000-0000-0000-0000-000000000000",
"clientUser": "anonymous"
}

3859
yarn.lock

File diff suppressed because it is too large Load Diff