@@ -11,6 +11,7 @@
|
||||
#import <FlipperKitNetworkPlugin/FlipperKitNetworkPlugin.h>
|
||||
#import <SKIOSNetworkPlugin/SKIOSNetworkAdapter.h>
|
||||
#import <FlipperKitReactPlugin/FlipperKitReactPlugin.h>
|
||||
#import <RNFSManager.h>
|
||||
|
||||
static void InitializeFlipper(UIApplication *application) {
|
||||
FlipperClient *client = [FlipperClient sharedClient];
|
||||
@@ -59,4 +60,9 @@ static void InitializeFlipper(UIApplication *application) {
|
||||
#endif
|
||||
}
|
||||
|
||||
- (void)application:(UIApplication *)application handleEventsForBackgroundURLSession:(NSString *)identifier completionHandler:(void (^)())completionHandler
|
||||
{
|
||||
[RNFSManager setCompletionHandlerForIdentifier:identifier completionHandler:completionHandler];
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
@@ -283,6 +283,8 @@ PODS:
|
||||
- glog
|
||||
- react-native-airplay-button (1.1.0):
|
||||
- React-Core
|
||||
- react-native-flipper (0.127.0):
|
||||
- React-Core
|
||||
- react-native-safe-area-context (3.3.2):
|
||||
- React-Core
|
||||
- react-native-slider (4.1.12):
|
||||
@@ -367,6 +369,8 @@ PODS:
|
||||
- React-Core
|
||||
- SDWebImage (~> 5.11.1)
|
||||
- SDWebImageWebPCoder (~> 0.8.4)
|
||||
- RNFS (2.18.0):
|
||||
- React
|
||||
- RNGestureHandler (2.1.0):
|
||||
- React-Core
|
||||
- RNLocalize (2.1.7):
|
||||
@@ -463,6 +467,7 @@ DEPENDENCIES:
|
||||
- React-jsinspector (from `../node_modules/react-native/ReactCommon/jsinspector`)
|
||||
- React-logger (from `../node_modules/react-native/ReactCommon/logger`)
|
||||
- react-native-airplay-button (from `../node_modules/react-native-airplay-button`)
|
||||
- react-native-flipper (from `../node_modules/react-native-flipper`)
|
||||
- react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`)
|
||||
- "react-native-slider (from `../node_modules/@react-native-community/slider`)"
|
||||
- react-native-track-player (from `../node_modules/react-native-track-player`)
|
||||
@@ -483,6 +488,7 @@ DEPENDENCIES:
|
||||
- "RNCMaskedView (from `../node_modules/@react-native-community/masked-view`)"
|
||||
- "RNCPicker (from `../node_modules/@react-native-community/picker`)"
|
||||
- RNFastImage (from `../node_modules/react-native-fast-image`)
|
||||
- RNFS (from `../node_modules/react-native-fs`)
|
||||
- RNGestureHandler (from `../node_modules/react-native-gesture-handler`)
|
||||
- RNLocalize (from `../node_modules/react-native-localize`)
|
||||
- RNReanimated (from `../node_modules/react-native-reanimated`)
|
||||
@@ -550,6 +556,8 @@ EXTERNAL SOURCES:
|
||||
:path: "../node_modules/react-native/ReactCommon/logger"
|
||||
react-native-airplay-button:
|
||||
:path: "../node_modules/react-native-airplay-button"
|
||||
react-native-flipper:
|
||||
:path: "../node_modules/react-native-flipper"
|
||||
react-native-safe-area-context:
|
||||
:path: "../node_modules/react-native-safe-area-context"
|
||||
react-native-slider:
|
||||
@@ -590,6 +598,8 @@ EXTERNAL SOURCES:
|
||||
:path: "../node_modules/@react-native-community/picker"
|
||||
RNFastImage:
|
||||
:path: "../node_modules/react-native-fast-image"
|
||||
RNFS:
|
||||
:path: "../node_modules/react-native-fs"
|
||||
RNGestureHandler:
|
||||
:path: "../node_modules/react-native-gesture-handler"
|
||||
RNLocalize:
|
||||
@@ -638,6 +648,7 @@ SPEC CHECKSUMS:
|
||||
React-jsinspector: d0374f7509d407d2264168b6d0fad0b54e300b85
|
||||
React-logger: 933f80c97c633ee8965d609876848148e3fef438
|
||||
react-native-airplay-button: 90c7ba52402c8e92342003b8a1ff78dfb4357a9e
|
||||
react-native-flipper: b9e2e817604af8da0d5a9ba20a8516e780e30f3c
|
||||
react-native-safe-area-context: 584dc04881deb49474363f3be89e4ca0e854c057
|
||||
react-native-slider: 6e9b86e76cce4b9e35b3403193a6432ed07e0c81
|
||||
react-native-track-player: 23dd515aacf1d36a0e522ef7fdbc55f13f26d4fb
|
||||
@@ -658,6 +669,7 @@ SPEC CHECKSUMS:
|
||||
RNCMaskedView: 0e1bc4bfa8365eba5fbbb71e07fbdc0555249489
|
||||
RNCPicker: 914b557e20b3b8317b084aca9ff4b4edb95f61e4
|
||||
RNFastImage: 1f2cab428712a4baaf78d6169eaec7f622556dd7
|
||||
RNFS: 3ab21fa6c56d65566d1fb26c2228e2b6132e5e32
|
||||
RNGestureHandler: e5c7cab5f214503dcefd6b2b0cefb050e1f51c4a
|
||||
RNLocalize: f567ea0e35116a641cdffe6683b0d212d568f32a
|
||||
RNReanimated: da3860204e5660c0dd66739936732197d359d753
|
||||
|
||||
96
package-lock.json
generated
@@ -29,6 +29,8 @@
|
||||
"react-native-collapsible": "^1.6.0",
|
||||
"react-native-dotenv": "^3.3.1",
|
||||
"react-native-fast-image": "^8.5.11",
|
||||
"react-native-flipper": "^0.127.0",
|
||||
"react-native-fs": "^2.18.0",
|
||||
"react-native-gesture-handler": "^2.1.0",
|
||||
"react-native-localize": "^2.1.7",
|
||||
"react-native-reanimated": "^2.3.1",
|
||||
@@ -40,6 +42,7 @@
|
||||
"react-native-webview": "^11.15.0",
|
||||
"react-redux": "^7.2.6",
|
||||
"redux": "^4.1.2",
|
||||
"redux-flipper": "^2.0.1",
|
||||
"redux-logger": "^3.0.6",
|
||||
"redux-persist": "^6.0.0",
|
||||
"styled-components": "^5.3.3"
|
||||
@@ -5306,6 +5309,11 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/base-64": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/base-64/-/base-64-0.1.0.tgz",
|
||||
"integrity": "sha1-eAqZyE59YAJgNhURxId2E78k9rs="
|
||||
},
|
||||
"node_modules/base/node_modules/define-property": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz",
|
||||
@@ -6048,6 +6056,14 @@
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.6.tgz",
|
||||
"integrity": "sha512-+ZAmfyWMT7TiIlzdqJgjMb7S4f1beorDbWbsocyK4RaiqA5RTX3K14bnBWmmA9QEM0gRdsjyyrEmcyga8Zsxmw=="
|
||||
},
|
||||
"node_modules/cycle": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/cycle/-/cycle-1.0.3.tgz",
|
||||
"integrity": "sha1-IegLK+hYD5i0aPN5QwZisEbDStI=",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-2.0.0.tgz",
|
||||
@@ -14002,6 +14018,28 @@
|
||||
"react-native": ">=0.60.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-flipper": {
|
||||
"version": "0.127.0",
|
||||
"resolved": "https://registry.npmjs.org/react-native-flipper/-/react-native-flipper-0.127.0.tgz",
|
||||
"integrity": "sha512-qloUyUOs9MoMVncIDDWeOxAPbomWJ3e4y0SgyCgq8joJEOXC7RvPWeEfUXp0EPyNhHGQV9a4RwzF6BWKFCR3Kg==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0",
|
||||
"react-native": ">0.62.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-fs": {
|
||||
"version": "2.18.0",
|
||||
"resolved": "https://registry.npmjs.org/react-native-fs/-/react-native-fs-2.18.0.tgz",
|
||||
"integrity": "sha512-9iQhkUNnN2JNED0in06JwZy88YEVyIGKWz4KLlQYxa5Y2U0U2AZh9FUHtA04oWj+xt2LlHh0LFPCzhmNsAsUDg==",
|
||||
"dependencies": {
|
||||
"base-64": "^0.1.0",
|
||||
"utf8": "^3.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react-native": "*",
|
||||
"react-native-windows": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-gesture-handler": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.1.0.tgz",
|
||||
@@ -14352,6 +14390,20 @@
|
||||
"@babel/runtime": "^7.9.2"
|
||||
}
|
||||
},
|
||||
"node_modules/redux-flipper": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux-flipper/-/redux-flipper-2.0.1.tgz",
|
||||
"integrity": "sha512-JqgnL+fUp3h2fPQRszItLkarTbIf5gjU4Sn0IY/ZxUo6oUUmMh4Lx2J7BBy78cE8KEW9prRRpvJ6NH2uc/QktA==",
|
||||
"dependencies": {
|
||||
"cycle": "^1.0.3",
|
||||
"dayjs": "^1.8.29"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react-native": ">=0.63.0",
|
||||
"react-native-flipper": ">=0.100.0",
|
||||
"redux": "^4"
|
||||
}
|
||||
},
|
||||
"node_modules/redux-logger": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/redux-logger/-/redux-logger-3.0.6.tgz",
|
||||
@@ -16156,6 +16208,11 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/utf8": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/utf8/-/utf8-3.0.0.tgz",
|
||||
"integrity": "sha512-E8VjFIQ/TyQgp+TZfS6l8yp/xWppSAHzidGiRrqe4bK4XP9pTRyKFgGJpO3SN7zdX4DeomTrwaseCHovfpFcqQ=="
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
@@ -20495,6 +20552,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"base-64": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/base-64/-/base-64-0.1.0.tgz",
|
||||
"integrity": "sha1-eAqZyE59YAJgNhURxId2E78k9rs="
|
||||
},
|
||||
"base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
@@ -21053,6 +21115,11 @@
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.6.tgz",
|
||||
"integrity": "sha512-+ZAmfyWMT7TiIlzdqJgjMb7S4f1beorDbWbsocyK4RaiqA5RTX3K14bnBWmmA9QEM0gRdsjyyrEmcyga8Zsxmw=="
|
||||
},
|
||||
"cycle": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/cycle/-/cycle-1.0.3.tgz",
|
||||
"integrity": "sha1-IegLK+hYD5i0aPN5QwZisEbDStI="
|
||||
},
|
||||
"data-urls": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-2.0.0.tgz",
|
||||
@@ -27265,6 +27332,21 @@
|
||||
"integrity": "sha512-cNW4bIJg3nvKaheG8vGMfqCt5LMWX9MS5+wMudgKIHbGO51spRr4sgnlhVgwHLcZ5aeNOVJ8CPRxDIWKRq/0QA==",
|
||||
"requires": {}
|
||||
},
|
||||
"react-native-flipper": {
|
||||
"version": "0.127.0",
|
||||
"resolved": "https://registry.npmjs.org/react-native-flipper/-/react-native-flipper-0.127.0.tgz",
|
||||
"integrity": "sha512-qloUyUOs9MoMVncIDDWeOxAPbomWJ3e4y0SgyCgq8joJEOXC7RvPWeEfUXp0EPyNhHGQV9a4RwzF6BWKFCR3Kg==",
|
||||
"requires": {}
|
||||
},
|
||||
"react-native-fs": {
|
||||
"version": "2.18.0",
|
||||
"resolved": "https://registry.npmjs.org/react-native-fs/-/react-native-fs-2.18.0.tgz",
|
||||
"integrity": "sha512-9iQhkUNnN2JNED0in06JwZy88YEVyIGKWz4KLlQYxa5Y2U0U2AZh9FUHtA04oWj+xt2LlHh0LFPCzhmNsAsUDg==",
|
||||
"requires": {
|
||||
"base-64": "^0.1.0",
|
||||
"utf8": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"react-native-gesture-handler": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.1.0.tgz",
|
||||
@@ -27540,6 +27622,15 @@
|
||||
"@babel/runtime": "^7.9.2"
|
||||
}
|
||||
},
|
||||
"redux-flipper": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux-flipper/-/redux-flipper-2.0.1.tgz",
|
||||
"integrity": "sha512-JqgnL+fUp3h2fPQRszItLkarTbIf5gjU4Sn0IY/ZxUo6oUUmMh4Lx2J7BBy78cE8KEW9prRRpvJ6NH2uc/QktA==",
|
||||
"requires": {
|
||||
"cycle": "^1.0.3",
|
||||
"dayjs": "^1.8.29"
|
||||
}
|
||||
},
|
||||
"redux-logger": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/redux-logger/-/redux-logger-3.0.6.tgz",
|
||||
@@ -28945,6 +29036,11 @@
|
||||
"mem": "^4.3.0"
|
||||
}
|
||||
},
|
||||
"utf8": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/utf8/-/utf8-3.0.0.tgz",
|
||||
"integrity": "sha512-E8VjFIQ/TyQgp+TZfS6l8yp/xWppSAHzidGiRrqe4bK4XP9pTRyKFgGJpO3SN7zdX4DeomTrwaseCHovfpFcqQ=="
|
||||
},
|
||||
"util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
|
||||
@@ -33,6 +33,8 @@
|
||||
"react-native-collapsible": "^1.6.0",
|
||||
"react-native-dotenv": "^3.3.1",
|
||||
"react-native-fast-image": "^8.5.11",
|
||||
"react-native-flipper": "^0.127.0",
|
||||
"react-native-fs": "^2.18.0",
|
||||
"react-native-gesture-handler": "^2.1.0",
|
||||
"react-native-localize": "^2.1.7",
|
||||
"react-native-reanimated": "^2.3.1",
|
||||
@@ -44,6 +46,7 @@
|
||||
"react-native-webview": "^11.15.0",
|
||||
"react-redux": "^7.2.6",
|
||||
"redux": "^4.1.2",
|
||||
"redux-flipper": "^2.0.1",
|
||||
"redux-logger": "^3.0.6",
|
||||
"redux-persist": "^6.0.0",
|
||||
"styled-components": "^5.3.3"
|
||||
|
||||
3
src/assets/arrow-clockwise.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="20" viewBox="0 0 16 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.74512 9.09863C8 9.09863 8.20215 9.01953 8.36035 8.85254L11.9639 5.22266C12.1572 5.0293 12.2451 4.81836 12.2451 4.57227C12.2451 4.33496 12.1484 4.10645 11.9639 3.93066L8.36035 0.265625C8.20215 0.0898438 8 0.00195312 7.74512 0.00195312C7.27051 0.00195312 6.89258 0.397461 6.89258 0.880859C6.89258 1.11816 6.98047 1.31152 7.12988 1.47852L9.23047 3.53516C8.81738 3.47363 8.39551 3.43848 7.97363 3.43848C3.62305 3.43848 0.142578 6.91895 0.142578 11.2783C0.142578 15.6377 3.64941 19.1445 8 19.1445C12.3594 19.1445 15.8574 15.6377 15.8574 11.2783C15.8574 10.751 15.4883 10.373 14.9609 10.373C14.4512 10.373 14.1084 10.751 14.1084 11.2783C14.1084 14.6709 11.3926 17.3955 8 17.3955C4.61621 17.3955 1.8916 14.6709 1.8916 11.2783C1.8916 7.85938 4.58984 5.15234 7.97363 5.15234C8.54492 5.15234 9.07227 5.19629 9.53809 5.27539L7.13867 7.64844C6.98047 7.80664 6.89258 8 6.89258 8.2373C6.89258 8.7207 7.27051 9.09863 7.74512 9.09863Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
3
src/assets/arrow-down-to-line.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 20 19" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 18.7334C14.9658 18.7334 19.0791 14.6289 19.0791 9.6543C19.0791 4.68848 14.9658 0.575195 9.99121 0.575195C5.02539 0.575195 0.920898 4.68848 0.920898 9.6543C0.920898 14.6289 5.03418 18.7334 10 18.7334ZM10 16.9492C5.95703 16.9492 2.71387 13.6973 2.71387 9.6543C2.71387 5.61133 5.94824 2.36816 9.99121 2.36816C14.0342 2.36816 17.2861 5.61133 17.2949 9.6543C17.2949 13.6973 14.043 16.9492 10 16.9492ZM9.98242 12.335C10.1758 12.335 10.3428 12.2559 10.4834 12.124L13.3838 9.20605C13.542 9.05664 13.5947 8.88965 13.5947 8.71387C13.5947 8.35352 13.3398 8.07227 12.9707 8.07227C12.7861 8.07227 12.6191 8.14258 12.4873 8.27441L11.9336 8.82812L10.5449 10.4014L10.6416 8.90723V5.24219C10.6416 4.85547 10.3691 4.57422 9.98242 4.57422C9.58691 4.57422 9.32324 4.85547 9.32324 5.24219V8.90723L9.42871 10.4102L8.01367 8.81934L7.50391 8.27441C7.38086 8.13379 7.22266 8.07227 7.02051 8.07227C6.65137 8.07227 6.3877 8.33594 6.3877 8.72266C6.3877 8.87207 6.4668 9.07422 6.58984 9.19727L9.49023 12.124C9.63086 12.2646 9.79785 12.335 9.98242 12.335ZM6.7832 14.2598H13.208C13.5859 14.2598 13.8584 13.9785 13.8584 13.6006C13.8584 13.2314 13.5859 12.959 13.208 12.959H6.7832C6.40527 12.959 6.13281 13.2314 6.13281 13.6006C6.13281 13.9785 6.40527 14.2598 6.7832 14.2598Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
3
src/assets/cloud-down-arrow.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="21" viewBox="0 0 24 21" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.63672 15.2002H10.9453V9.82129C10.9453 9.25 11.4287 8.78418 12 8.78418C12.5713 8.78418 13.0459 9.25 13.0459 9.82129V15.2002H18.8643C21.4658 15.2002 23.2324 13.6006 23.2324 11.4033C23.2324 9.58398 22.1689 7.98438 20.4199 7.27246C20.4287 3.2998 17.5635 0.43457 13.8809 0.43457C11.543 0.43457 9.78516 1.63867 8.68652 3.22949C6.58594 2.71094 4.10742 4.30176 4.01953 6.78027C2.00684 7.13184 0.767578 8.90723 0.767578 10.9902C0.767578 13.3193 2.70996 15.2002 5.63672 15.2002ZM12 20.5791C12.2109 20.5791 12.4131 20.5088 12.624 20.2979L15.5771 17.4502C15.7266 17.3008 15.8057 17.1426 15.8057 16.9316C15.8057 16.5186 15.4717 16.2197 15.0674 16.2197C14.8652 16.2197 14.6631 16.3076 14.5225 16.4658L13.3008 17.7666L12.7295 18.417L12.8174 17.1162V15.0859C12.8174 14.6465 12.4482 14.2773 12 14.2773C11.543 14.2773 11.1738 14.6465 11.1738 15.0859V17.1162L11.2617 18.417L10.6992 17.7666L9.46875 16.4658C9.32812 16.3076 9.11719 16.2197 8.91504 16.2197C8.51074 16.2197 8.18555 16.5186 8.18555 16.9316C8.18555 17.1426 8.27344 17.3008 8.42285 17.4502L11.376 20.2979C11.5869 20.5088 11.7803 20.5791 12 20.5791Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
3
src/assets/cloud-exclamation-mark.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="16" viewBox="0 0 24 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18.3281 15.6592C21.0703 15.6592 23.2324 13.6465 23.2324 11.1328C23.2324 9.26074 22.1689 7.59961 20.4199 6.87012C20.4287 2.89746 17.5635 0.0322266 13.8809 0.0322266C11.543 0.0322266 9.78516 1.23633 8.68652 2.83594C6.4541 2.23828 4.10742 3.89941 4.01953 6.37793C2.00684 6.73828 0.767578 8.54004 0.767578 10.7461C0.767578 13.418 3.10547 15.6504 6.18164 15.6504L18.3281 15.6592ZM18.3281 13.9014H6.19043C4.09863 13.9014 2.54297 12.4424 2.54297 10.7461C2.54297 8.98828 3.62402 7.6875 5.41699 7.6875C5.54883 7.6875 5.60156 7.61719 5.59277 7.49414C5.54004 4.88379 7.41211 3.9873 9.30176 4.58496C9.41602 4.62012 9.48633 4.59375 9.53906 4.49707C10.4092 2.96777 11.6924 1.78125 13.8721 1.78125C16.6318 1.78125 18.6006 3.96973 18.7324 6.52734C18.7588 7.00195 18.7236 7.51172 18.6885 7.93359C18.6709 8.05664 18.7236 8.12695 18.8379 8.14453C20.4287 8.45215 21.457 9.57715 21.457 11.1328C21.457 12.6709 20.0947 13.9014 18.3281 13.9014ZM12.0088 9.4541C12.4658 9.4541 12.7207 9.19922 12.7471 8.70703L12.8701 5.75391C12.8965 5.24414 12.5098 4.88379 12 4.88379C11.4814 4.88379 11.1123 5.23535 11.1387 5.75391L11.2529 8.71582C11.2793 9.19043 11.5342 9.4541 12.0088 9.4541ZM12 12.2402C12.5537 12.2402 12.9932 11.8447 12.9932 11.3174C12.9932 10.7725 12.5625 10.3857 12 10.3857C11.4375 10.3857 11.0068 10.7812 11.0068 11.3174C11.0068 11.8447 11.4463 12.2402 12 12.2402Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
3
src/assets/cloud.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18.3281 15.6592C21.0703 15.6592 23.2324 13.6465 23.2324 11.1328C23.2324 9.26074 22.1689 7.59961 20.4199 6.87012C20.4287 2.89746 17.5635 0.0322266 13.8809 0.0322266C11.543 0.0322266 9.78516 1.23633 8.68652 2.83594C6.4541 2.23828 4.10742 3.89941 4.01953 6.37793C2.00684 6.73828 0.767578 8.54004 0.767578 10.7461C0.767578 13.418 3.10547 15.6504 6.18164 15.6504L18.3281 15.6592ZM18.3281 13.9014H6.19043C4.09863 13.9014 2.54297 12.4424 2.54297 10.7461C2.54297 8.98828 3.62402 7.6875 5.41699 7.6875C5.54883 7.6875 5.60156 7.61719 5.59277 7.49414C5.54004 4.88379 7.41211 3.9873 9.30176 4.58496C9.41602 4.62012 9.48633 4.59375 9.53906 4.49707C10.4092 2.96777 11.6924 1.78125 13.8721 1.78125C16.6318 1.78125 18.6006 3.96973 18.7324 6.52734C18.7588 7.00195 18.7236 7.51172 18.6885 7.93359C18.6709 8.05664 18.7236 8.12695 18.8379 8.14453C20.4287 8.45215 21.457 9.57715 21.457 11.1328C21.457 12.6709 20.0947 13.9014 18.3281 13.9014Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1003 B |
3
src/assets/internal-drive.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 22 17" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0.400391 12.2139C0.400391 14.5781 2.17578 16.3535 4.71582 16.3535H17.2842C19.8242 16.3535 21.5996 14.5781 21.5996 12.2139C21.5996 11.502 21.3975 10.8604 21.1514 10.2803L18.1279 3.19629C17.5127 1.74609 16.291 0.981445 14.6562 0.981445H7.35254C5.70898 0.981445 4.4873 1.74609 3.88086 3.19629L0.875 10.2451C0.620117 10.834 0.400391 11.4844 0.400391 12.2139ZM3.57324 8.26758L5.48926 3.5918C5.78809 2.83594 6.46484 2.44043 7.37012 2.44043H14.6299C15.5439 2.44043 16.2207 2.83594 16.5195 3.5918L18.4355 8.26758C18.084 8.15332 17.6973 8.08301 17.2842 8.08301H4.71582C4.30273 8.08301 3.9248 8.15332 3.57324 8.26758ZM2.08789 12.2139C2.08789 10.8164 3.13379 9.77051 4.71582 9.77051H17.2842C18.8662 9.77051 19.9121 10.8164 19.9121 12.2139C19.9121 13.7607 18.8662 14.6572 17.2842 14.6572H4.71582C3.13379 14.6572 2.08789 13.6201 2.08789 12.2139ZM8.45117 13.084C8.45117 13.3828 8.68848 13.6113 8.9873 13.6113C9.27734 13.6113 9.50586 13.3828 9.50586 13.084V11.3525C9.50586 11.0625 9.27734 10.8252 8.9873 10.8252C8.68848 10.8252 8.45117 11.0625 8.45117 11.3525V13.084ZM10.4727 13.084C10.4727 13.3828 10.7012 13.6113 11 13.6113C11.29 13.6113 11.5273 13.3828 11.5273 13.084V11.3525C11.5273 11.0625 11.29 10.8252 11 10.8252C10.7012 10.8252 10.4727 11.0625 10.4727 11.3525V13.084ZM12.4854 13.084C12.4854 13.3828 12.7227 13.6113 13.0215 13.6113C13.3115 13.6113 13.5488 13.3828 13.5488 13.084V11.3525C13.5488 11.0625 13.3115 10.8252 13.0215 10.8252C12.7227 10.8252 12.4854 11.0625 12.4854 11.3525V13.084ZM14.5068 13.084C14.5068 13.3828 14.7441 13.6113 15.043 13.6113C15.333 13.6113 15.5703 13.3828 15.5703 13.084V11.3525C15.5703 11.0625 15.333 10.8252 15.043 10.8252C14.7441 10.8252 14.5068 11.0625 14.5068 11.3525V13.084ZM16.5283 13.084C16.5283 13.3828 16.7656 13.6113 17.0645 13.6113C17.3545 13.6113 17.583 13.3828 17.583 13.084V11.3525C17.583 11.0625 17.3545 10.8252 17.0645 10.8252C16.7656 10.8252 16.5283 11.0625 16.5283 11.3525V13.084Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
3
src/assets/trash.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9.84277 22.4785H18.166C19.3701 22.4785 20.0732 21.8369 20.126 20.6416L20.6797 7.94141H21.8926C22.3408 7.94141 22.6836 7.58984 22.6836 7.15039C22.6836 6.71094 22.332 6.37695 21.8926 6.37695H18.0781V5.05859C18.0781 3.65234 17.1729 2.81738 15.6611 2.81738H12.3213C10.8096 2.81738 9.9043 3.65234 9.9043 5.05859V6.37695H6.10742C5.66797 6.37695 5.31641 6.71973 5.31641 7.15039C5.31641 7.59863 5.66797 7.94141 6.10742 7.94141H7.3291L7.8916 20.6416C7.93555 21.8369 8.63867 22.4785 9.84277 22.4785ZM11.7324 5.1377C11.7324 4.74219 12.0049 4.4873 12.4443 4.4873H15.5469C15.9863 4.4873 16.2588 4.74219 16.2588 5.1377V6.37695H11.7324V5.1377ZM11.1787 19.7803C10.8271 19.7803 10.5811 19.5518 10.5723 19.2002L10.3086 9.86621C10.2998 9.51465 10.5459 9.27734 10.915 9.27734C11.2666 9.27734 11.5127 9.50586 11.5215 9.85742L11.7852 19.1914C11.8027 19.543 11.5566 19.7803 11.1787 19.7803ZM14 19.7803C13.6309 19.7803 13.3848 19.5518 13.3848 19.2002V9.85742C13.3848 9.51465 13.6309 9.27734 14 9.27734C14.3691 9.27734 14.624 9.51465 14.624 9.85742V19.2002C14.624 19.5518 14.3691 19.7803 14 19.7803ZM16.8213 19.7891C16.4434 19.7891 16.1973 19.543 16.2148 19.2002L16.4785 9.85742C16.4873 9.50586 16.7334 9.27734 17.085 9.27734C17.4541 9.27734 17.7002 9.51465 17.6914 9.86621L17.4277 19.2002C17.4189 19.5518 17.1729 19.7891 16.8213 19.7891Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -20,6 +20,10 @@ const BaseButton = styled.Pressable`
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-grow: 1;
|
||||
|
||||
${(props) => props.disabled && css`
|
||||
opacity: 0.25;
|
||||
`}
|
||||
`;
|
||||
|
||||
const ButtonText = styled.Text<{ active?: boolean }>`
|
||||
@@ -32,7 +36,7 @@ const ButtonText = styled.Text<{ active?: boolean }>`
|
||||
`;
|
||||
|
||||
const Button = React.forwardRef<View, ButtonProps>(function Button(props, ref) {
|
||||
const { icon: Icon, title, ...rest } = props;
|
||||
const { icon: Icon, title, disabled, ...rest } = props;
|
||||
const defaultStyles = useDefaultStyles();
|
||||
const [isPressed, setPressed] = useState(false);
|
||||
const handlePressIn = useCallback(() => setPressed(true), []);
|
||||
@@ -41,6 +45,8 @@ const Button = React.forwardRef<View, ButtonProps>(function Button(props, ref) {
|
||||
return (
|
||||
<BaseButton
|
||||
{...rest}
|
||||
disabled={disabled}
|
||||
// @ts-expect-error styled-components has outdated react-native typings
|
||||
ref={ref}
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
@@ -51,8 +57,8 @@ const Button = React.forwardRef<View, ButtonProps>(function Button(props, ref) {
|
||||
>
|
||||
{Icon &&
|
||||
<Icon
|
||||
width={12}
|
||||
height={12}
|
||||
width={14}
|
||||
height={14}
|
||||
fill={isPressed ? '#fff' : THEME_COLOR}
|
||||
style={{
|
||||
marginRight: 8,
|
||||
|
||||
98
src/components/DownloadIcon.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import React, { useEffect, useMemo, useRef } from 'react';
|
||||
import { useTypedSelector } from 'store';
|
||||
import CloudIcon from 'assets/cloud.svg';
|
||||
import CloudExclamationMarkIcon from 'assets/cloud-exclamation-mark.svg';
|
||||
import InternalDriveIcon from 'assets/internal-drive.svg';
|
||||
import useDefaultStyles from './Colors';
|
||||
import { EntityId } from '@reduxjs/toolkit';
|
||||
import Svg, { Circle, CircleProps } from 'react-native-svg';
|
||||
import { Animated, Easing } from 'react-native';
|
||||
|
||||
interface DownloadIconProps {
|
||||
trackId: EntityId;
|
||||
size?: number;
|
||||
fill?: string;
|
||||
}
|
||||
|
||||
function DownloadIcon({ trackId, size = 16, fill }: DownloadIconProps) {
|
||||
// determine styles
|
||||
const defaultStyles = useDefaultStyles();
|
||||
const iconFill = fill || defaultStyles.textHalfOpacity.color;
|
||||
|
||||
// Get download icon from state
|
||||
const entity = useTypedSelector((state) => state.downloads.entities[trackId]);
|
||||
|
||||
// Memoize calculations for radius and circumference of the circle
|
||||
const radius = useMemo(() => size / 2, [size]);
|
||||
const circumference = useMemo(() => radius * 2 * Math.PI, [radius]);
|
||||
|
||||
// Initialize refs for the circle and the animated value
|
||||
const circleRef = useRef<Circle>(null);
|
||||
const offsetAnimation = useRef(new Animated.Value(entity?.progress || 0)).current;
|
||||
|
||||
// Whenever the progress changes, trigger the animation
|
||||
useEffect(() => {
|
||||
Animated.timing(offsetAnimation, {
|
||||
toValue: (circumference * (1 - (entity?.progress || 0))),
|
||||
duration: 250,
|
||||
useNativeDriver: false,
|
||||
easing: Easing.ease,
|
||||
}).start();
|
||||
}, [entity?.progress, offsetAnimation, circumference]);
|
||||
|
||||
// On mount, subscribe to changes in the animation value and then
|
||||
// apply them to the circle using native props
|
||||
useEffect(() => {
|
||||
const subscription = offsetAnimation.addListener((offset) => {
|
||||
// @ts-expect-error undocumented functionality
|
||||
const setNativeProps = circleRef.current?.setNativeProps as (props: CircleProps) => void | undefined;
|
||||
setNativeProps?.({ strokeDashoffset: offset.value });
|
||||
});
|
||||
|
||||
return () => offsetAnimation.removeListener(subscription);
|
||||
}, [offsetAnimation]);
|
||||
|
||||
if (!entity) {
|
||||
return (
|
||||
<CloudIcon width={size} height={size} fill={iconFill} />
|
||||
);
|
||||
}
|
||||
|
||||
const { isComplete, isFailed } = entity;
|
||||
|
||||
if (isComplete) {
|
||||
return (
|
||||
<InternalDriveIcon width={size} height={size} fill={iconFill} />
|
||||
);
|
||||
}
|
||||
|
||||
if (isFailed) {
|
||||
return (
|
||||
<CloudExclamationMarkIcon width={size} height={size} fill={iconFill} />
|
||||
);
|
||||
}
|
||||
|
||||
if (!isComplete && !isFailed) {
|
||||
return (
|
||||
<Svg width={size} height={size} transform={[{ rotate: '-90deg' }]}>
|
||||
<Circle
|
||||
cx={radius}
|
||||
cy={radius}
|
||||
r={radius - 1}
|
||||
stroke={iconFill}
|
||||
// @ts-expect-error react-native-svg has outdated react-native typings
|
||||
ref={circleRef}
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray={[ circumference, circumference ]}
|
||||
strokeDashoffset={circumference}
|
||||
strokeLinecap='round'
|
||||
fill='transparent'
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default DownloadIcon;
|
||||
@@ -34,6 +34,7 @@ const ListButton: React.FC<TouchableOpacityProps> = ({ children, ...props }) =>
|
||||
const handlePressOut = useCallback(() => setPressed(false), []);
|
||||
|
||||
return (
|
||||
// @ts-expect-error styled-components has outdated react-native typings
|
||||
<Container
|
||||
{...props}
|
||||
onPressIn={handlePressIn}
|
||||
|
||||
@@ -44,5 +44,16 @@
|
||||
"playlist": "Playlist",
|
||||
"play-playlist": "Play Playlist",
|
||||
"shuffle-album": "Shuffle Album",
|
||||
"shuffle-playlist": "Shuffle Playlist"
|
||||
"shuffle-playlist": "Shuffle Playlist",
|
||||
"downloads": "Downloads",
|
||||
"download-track": "Download Track",
|
||||
"download-album": "Download Album",
|
||||
"download-playlist": "Download Playlist",
|
||||
"no-downloads": "You have not yet downloaded any tracks",
|
||||
"delete-track": "Delete Track",
|
||||
"delete-all-tracks": "Delete All Tracks",
|
||||
"delete-album": "Delete Album",
|
||||
"delete-playlist": "Delete Playlist",
|
||||
"total-download-size": "Total Download Size",
|
||||
"retry-failed-downloads": "Retry Failed Downloads"
|
||||
}
|
||||
@@ -43,4 +43,14 @@ export type LocaleKeys = 'play-next'
|
||||
| 'playlist'
|
||||
| 'play-playlist'
|
||||
| 'shuffle-album'
|
||||
| 'shuffle-playlist'
|
||||
| 'shuffle-playlist'
|
||||
| 'downloads'
|
||||
| 'download-track'
|
||||
| 'download-album'
|
||||
| 'download-playlist'
|
||||
| 'delete-album'
|
||||
| 'delete-playlist'
|
||||
| 'delete-track'
|
||||
| 'total-download-size'
|
||||
| 'no-downloads'
|
||||
| 'retry-failed-downloads'
|
||||
144
src/screens/Downloads/index.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import useDefaultStyles from 'components/Colors';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { FlatListProps, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { FlatList } from 'react-native-gesture-handler';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useTypedSelector } from 'store';
|
||||
import formatBytes from 'utility/formatBytes';
|
||||
import TrashIcon from 'assets/trash.svg';
|
||||
import ArrowClockwise from 'assets/arrow-clockwise.svg';
|
||||
import { THEME_COLOR } from 'CONSTANTS';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { EntityId } from '@reduxjs/toolkit';
|
||||
import { downloadTrack, removeDownloadedTrack } from 'store/downloads/actions';
|
||||
import Button from 'components/Button';
|
||||
import { t } from 'i18n-js';
|
||||
import DownloadIcon from 'components/DownloadIcon';
|
||||
import styled from 'styled-components/native';
|
||||
|
||||
const DownloadedTrack = styled.View`
|
||||
flex: 1 0 auto;
|
||||
flex-direction: row;
|
||||
padding: 8px 0;
|
||||
align-items: center;
|
||||
margin: 0 20px;
|
||||
border-bottom-width: 1px;
|
||||
`;
|
||||
|
||||
function Downloads() {
|
||||
const defaultStyles = useDefaultStyles();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { entities, ids } = useTypedSelector((state) => state.downloads);
|
||||
const tracks = useTypedSelector((state) => state.music.tracks.entities);
|
||||
|
||||
// Calculate the total download size
|
||||
const totalDownloadSize = useMemo(() => (
|
||||
ids?.reduce<number>((sum, id) => sum + (entities[id]?.size || 0), 0)
|
||||
), [ids, entities]);
|
||||
|
||||
/**
|
||||
* Handlers for actions in this components
|
||||
*/
|
||||
|
||||
// Delete a single downloaded track
|
||||
const handleDelete = useCallback((id: EntityId) => {
|
||||
dispatch(removeDownloadedTrack(id));
|
||||
}, [dispatch]);
|
||||
|
||||
// Delete all downloaded tracks
|
||||
const handleDeleteAllTracks = useCallback(() => ids.forEach(handleDelete), [handleDelete, ids]);
|
||||
|
||||
// Retry a single failed track
|
||||
const retryTrack = useCallback((id: EntityId) => {
|
||||
dispatch(downloadTrack(id));
|
||||
}, [dispatch]);
|
||||
|
||||
// Retry all failed tracks
|
||||
const failedIds = useMemo(() => ids.filter((id) => !entities[id]?.isComplete), [ids, entities]);
|
||||
const handleRetryFailed = useCallback(() => (
|
||||
failedIds.forEach(retryTrack)
|
||||
), [failedIds, retryTrack]);
|
||||
|
||||
/**
|
||||
* Render section
|
||||
*/
|
||||
|
||||
const ListHeaderComponent = useMemo(() => (
|
||||
<View style={{ marginHorizontal: 20, marginBottom: 12 }}>
|
||||
<Text style={[{ textAlign: 'center', marginVertical: 6 }, defaultStyles.textHalfOpacity]}>
|
||||
{t('total-download-size')}: {formatBytes(totalDownloadSize)}
|
||||
</Text>
|
||||
<Button
|
||||
icon={TrashIcon}
|
||||
title={t('delete-all-tracks')}
|
||||
onPress={handleDeleteAllTracks}
|
||||
disabled={!ids.length}
|
||||
style={{ marginTop: 8 }}
|
||||
/>
|
||||
<Button
|
||||
icon={ArrowClockwise}
|
||||
title={t('retry-failed-downloads')}
|
||||
onPress={handleRetryFailed}
|
||||
disabled={failedIds.length === 0}
|
||||
style={{ marginTop: 4 }}
|
||||
/>
|
||||
</View>
|
||||
), [totalDownloadSize, defaultStyles, failedIds.length, handleRetryFailed, handleDeleteAllTracks, ids.length]);
|
||||
|
||||
const renderItem = useCallback<NonNullable<FlatListProps<EntityId>['renderItem']>>(({ item }) => (
|
||||
<DownloadedTrack style={defaultStyles.border}>
|
||||
<View style={{ marginRight: 12 }}>
|
||||
<DownloadIcon trackId={item} />
|
||||
</View>
|
||||
<View style={{ flexShrink: 1, marginRight: 8 }}>
|
||||
<Text style={{ fontSize: 16, marginBottom: 4 }} numberOfLines={1}>
|
||||
{tracks[item]?.Name}
|
||||
</Text>
|
||||
<Text style={[{ flexShrink: 1, fontSize: 11 }, defaultStyles.textHalfOpacity]} numberOfLines={1}>
|
||||
{tracks[item]?.AlbumArtist} ({tracks[item]?.Album})
|
||||
</Text>
|
||||
</View>
|
||||
<View style={{ marginLeft: 'auto', flexDirection: 'row', alignItems: 'center' }}>
|
||||
{entities[item]?.isComplete && entities[item]?.size ? (
|
||||
<Text style={[defaultStyles.textHalfOpacity, { marginRight: 6, fontSize: 12 }]}>
|
||||
{formatBytes(entities[item]?.size || 0)}
|
||||
</Text>
|
||||
) : null}
|
||||
<TouchableOpacity onPress={() => handleDelete(item)}>
|
||||
<TrashIcon height={24} width={24} fill={THEME_COLOR} />
|
||||
</TouchableOpacity>
|
||||
{!entities[item]?.isComplete && (
|
||||
<TouchableOpacity onPress={() => retryTrack(item)}>
|
||||
<ArrowClockwise height={18} width={18} fill={THEME_COLOR} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</DownloadedTrack>
|
||||
), [entities, retryTrack, handleDelete, defaultStyles, tracks]);
|
||||
|
||||
// If no tracks have been downloaded, show a short message describing this
|
||||
if (!ids.length) {
|
||||
return (
|
||||
<View style={{ margin: 24, flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Text style={[{ textAlign: 'center'}, defaultStyles.textHalfOpacity]}>
|
||||
{t('no-downloads')}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1 }}>
|
||||
<FlatList
|
||||
data={ids}
|
||||
style={{ flex: 1 }}
|
||||
contentContainerStyle={{ flexGrow: 1 }}
|
||||
ListHeaderComponent={ListHeaderComponent}
|
||||
renderItem={renderItem}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
export default Downloads;
|
||||
@@ -1,25 +1,26 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { MusicStackParams } from '../types';
|
||||
import { useRoute, RouteProp } from '@react-navigation/native';
|
||||
import { useAppDispatch, useTypedSelector } from 'store';
|
||||
import { useTypedSelector } from 'store';
|
||||
import TrackListView from './components/TrackListView';
|
||||
import { fetchTracksByAlbum } from 'store/music/actions';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
import { ALBUM_CACHE_AMOUNT_OF_DAYS } from 'CONSTANTS';
|
||||
import { t } from '@localisation';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
type Route = RouteProp<MusicStackParams, 'Album'>;
|
||||
|
||||
const Album: React.FC = () => {
|
||||
const { params: { id } } = useRoute<Route>();
|
||||
const dispatch = useAppDispatch();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// Retrieve the album data from the store
|
||||
const album = useTypedSelector((state) => state.music.albums.entities[id]);
|
||||
const albumTracks = useTypedSelector((state) => state.music.tracks.byAlbum[id]);
|
||||
|
||||
// Define a function for refreshing this entity
|
||||
const refresh = useCallback(() => dispatch(fetchTracksByAlbum(id)), [id, dispatch]);
|
||||
const refresh = useCallback(() => { dispatch(fetchTracksByAlbum(id)); }, [id, dispatch]);
|
||||
|
||||
// Auto-fetch the track data periodically
|
||||
useEffect(() => {
|
||||
@@ -37,6 +38,8 @@ const Album: React.FC = () => {
|
||||
refresh={refresh}
|
||||
playButtonText={t('play-album')}
|
||||
shuffleButtonText={t('shuffle-album')}
|
||||
downloadButtonText={t('download-album')}
|
||||
deleteButtonText={t('delete-album')}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { MusicStackParams } from '../types';
|
||||
import { useRoute, RouteProp } from '@react-navigation/native';
|
||||
import { useAppDispatch, useTypedSelector } from 'store';
|
||||
import { useTypedSelector } from 'store';
|
||||
import TrackListView from './components/TrackListView';
|
||||
import { fetchTracksByPlaylist } from 'store/music/actions';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
import { ALBUM_CACHE_AMOUNT_OF_DAYS } from 'CONSTANTS';
|
||||
import { t } from '@localisation';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
type Route = RouteProp<MusicStackParams, 'Album'>;
|
||||
|
||||
const Playlist: React.FC = () => {
|
||||
const { params: { id } } = useRoute<Route>();
|
||||
const dispatch = useAppDispatch();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// Retrieve the album data from the store
|
||||
const playlist = useTypedSelector((state) => state.music.playlists.entities[id]);
|
||||
@@ -37,6 +38,8 @@ const Playlist: React.FC = () => {
|
||||
listNumberingStyle='index'
|
||||
playButtonText={t('play-playlist')}
|
||||
shuffleButtonText={t('shuffle-playlist')}
|
||||
downloadButtonText={t('download-playlist')}
|
||||
deleteButtonText={t('delete-playlist')}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import Input from 'components/Input';
|
||||
import { ActivityIndicator, Text, TextInput, View } from 'react-native';
|
||||
import styled from 'styled-components/native';
|
||||
import { useAppDispatch, useTypedSelector } from 'store';
|
||||
import { useTypedSelector } from 'store';
|
||||
import Fuse from 'fuse.js';
|
||||
import { Album, AlbumTrack } from 'store/music/types';
|
||||
import { FlatList } from 'react-native-gesture-handler';
|
||||
@@ -15,6 +15,7 @@ import { t } from '@localisation';
|
||||
import useDefaultStyles from 'components/Colors';
|
||||
import { searchAndFetchAlbums } from 'store/music/actions';
|
||||
import { debounce } from 'lodash';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
const Container = styled.View`
|
||||
padding: 0 20px;
|
||||
@@ -96,7 +97,7 @@ export default function Search() {
|
||||
// Prepare helpers
|
||||
const navigation = useNavigation<MusicNavigationProp>();
|
||||
const getImage = useGetImage();
|
||||
const dispatch = useAppDispatch();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
/**
|
||||
* Since it is impractical to have a global fuse variable, we need to
|
||||
@@ -118,6 +119,7 @@ export default function Search() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const fetchJellyfinResults = useCallback(debounce(async (searchTerm: string, currentResults: CombinedResults) => {
|
||||
// First, query the Jellyfin API
|
||||
// @ts-expect-error need to fix this with AppDispatch
|
||||
const { payload } = await dispatch(searchAndFetchAlbums({ term: searchTerm }));
|
||||
|
||||
// Convert the current results to album ids
|
||||
@@ -206,6 +208,7 @@ export default function Search() {
|
||||
const HeaderComponent = React.useMemo(() => (
|
||||
<Container>
|
||||
<Input
|
||||
// @ts-expect-error styled-components has outdated react-native typings
|
||||
ref={searchElement}
|
||||
value={searchTerm}
|
||||
onChangeText={setSearchTerm}
|
||||
@@ -265,11 +268,11 @@ export default function Search() {
|
||||
// ListFooterComponent={FooterComponent}
|
||||
extraData={[searchTerm, albums]}
|
||||
/>
|
||||
<FullSizeContainer>
|
||||
{(searchTerm.length && !jellyfinResults.length && !fuseResults.length && !isLoading)
|
||||
? <Text style={{ textAlign: 'center', opacity: 0.5, fontSize: 18 }}>{t('no-results')}</Text>
|
||||
: null}
|
||||
</FullSizeContainer>
|
||||
{(searchTerm.length && !jellyfinResults.length && !fuseResults.length && !isLoading) ? (
|
||||
<FullSizeContainer>
|
||||
<Text style={{ textAlign: 'center', opacity: 0.5, fontSize: 18 }}>{t('no-results')}</Text>
|
||||
</FullSizeContainer>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,12 @@ import usePlayTracks from 'utility/usePlayTracks';
|
||||
import { EntityId } from '@reduxjs/toolkit';
|
||||
import { WrappableButtonRow, WrappableButton } from 'components/WrappableButtonRow';
|
||||
import { MusicNavigationProp } from 'screens/Music/types';
|
||||
import DownloadIcon from 'components/DownloadIcon';
|
||||
import CloudDownArrow from 'assets/cloud-down-arrow.svg';
|
||||
import Trash from 'assets/trash.svg';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { downloadTrack, removeDownloadedTrack } from 'store/downloads/actions';
|
||||
import { selectDownloadedTracks } from 'store/downloads/selectors';
|
||||
|
||||
const Screen = Dimensions.get('screen');
|
||||
|
||||
@@ -44,14 +50,14 @@ const AlbumImage = styled(FastImage)`
|
||||
`;
|
||||
|
||||
const TrackContainer = styled.View<{isPlaying: boolean}>`
|
||||
padding: 15px;
|
||||
padding: 15px 4px;
|
||||
border-bottom-width: 1px;
|
||||
flex-direction: row;
|
||||
|
||||
${props => props.isPlaying && css`
|
||||
background-color: ${THEME_COLOR}16;
|
||||
margin: 0 -20px;
|
||||
padding: 15px 35px;
|
||||
padding: 15px 24px;
|
||||
`}
|
||||
`;
|
||||
|
||||
@@ -63,6 +69,8 @@ interface TrackListViewProps {
|
||||
refresh: () => void;
|
||||
playButtonText: string;
|
||||
shuffleButtonText: string;
|
||||
downloadButtonText: string;
|
||||
deleteButtonText: string;
|
||||
listNumberingStyle?: 'album' | 'index';
|
||||
}
|
||||
|
||||
@@ -74,6 +82,8 @@ const TrackListView: React.FC<TrackListViewProps> = ({
|
||||
refresh,
|
||||
playButtonText,
|
||||
shuffleButtonText,
|
||||
downloadButtonText,
|
||||
deleteButtonText,
|
||||
listNumberingStyle = 'album',
|
||||
}) => {
|
||||
const defaultStyles = useDefaultStyles();
|
||||
@@ -81,24 +91,32 @@ const TrackListView: React.FC<TrackListViewProps> = ({
|
||||
// Retrieve state
|
||||
const tracks = useTypedSelector((state) => state.music.tracks.entities);
|
||||
const isLoading = useTypedSelector((state) => state.music.tracks.isLoading);
|
||||
const downloadedTracks = useTypedSelector(selectDownloadedTracks(trackIds));
|
||||
|
||||
// Retrieve helpers
|
||||
const getImage = useGetImage();
|
||||
const playTracks = usePlayTracks();
|
||||
const { track: currentTrack } = useCurrentTrack();
|
||||
const navigation = useNavigation<MusicNavigationProp>();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// Setup callbacks
|
||||
const playEntity = useCallback(() => { playTracks(trackIds); }, [playTracks, trackIds]);
|
||||
const shuffleEntity = useCallback(() => { playTracks(trackIds, true, true); }, [playTracks, trackIds]);
|
||||
const shuffleEntity = useCallback(() => { playTracks(trackIds, { shuffle: true }); }, [playTracks, trackIds]);
|
||||
const selectTrack = useCallback(async (index: number) => {
|
||||
await playTracks(trackIds, false);
|
||||
await playTracks(trackIds, { play: false });
|
||||
await TrackPlayer.skip(index);
|
||||
await TrackPlayer.play();
|
||||
}, [playTracks, trackIds]);
|
||||
const longPressTrack = useCallback((index: number) => {
|
||||
navigation.navigate('TrackPopupMenu', { trackId: trackIds[index] });
|
||||
}, [navigation, trackIds]);
|
||||
const downloadAllTracks = useCallback(() => {
|
||||
trackIds.forEach((trackId) => dispatch(downloadTrack(trackId)));
|
||||
}, [dispatch, trackIds]);
|
||||
const deleteAllTracks = useCallback(() => {
|
||||
downloadedTracks.forEach((trackId) => dispatch(removeDownloadedTrack(trackId)));
|
||||
}, [dispatch, downloadedTracks]);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
@@ -145,9 +163,26 @@ const TrackListView: React.FC<TrackListViewProps> = ({
|
||||
>
|
||||
{tracks[trackId]?.Name}
|
||||
</Text>
|
||||
<View style={{ marginLeft: 'auto' }}>
|
||||
<DownloadIcon trackId={trackId} />
|
||||
</View>
|
||||
</TrackContainer>
|
||||
</TouchableHandler>
|
||||
)}
|
||||
<WrappableButtonRow style={{ marginTop: 24 }}>
|
||||
<WrappableButton
|
||||
icon={CloudDownArrow}
|
||||
title={downloadButtonText}
|
||||
onPress={downloadAllTracks}
|
||||
disabled={downloadedTracks.length === trackIds.length}
|
||||
/>
|
||||
<WrappableButton
|
||||
icon={Trash}
|
||||
title={deleteButtonText}
|
||||
onPress={deleteAllTracks}
|
||||
disabled={downloadedTracks.length === 0}
|
||||
/>
|
||||
</WrappableButtonRow>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
@@ -33,7 +33,7 @@ export default class ProgressBar extends Component<{}, State> {
|
||||
duration: 0,
|
||||
};
|
||||
|
||||
timer: NodeJS.Timeout | null = null;
|
||||
timer: number | null = null;
|
||||
|
||||
componentDidMount() {
|
||||
this.timer = setInterval(this.updateProgress, 500);
|
||||
|
||||
@@ -10,10 +10,14 @@ import useDefaultStyles from 'components/Colors';
|
||||
import Text from 'components/Text';
|
||||
import Button from 'components/Button';
|
||||
import { THEME_COLOR } from 'CONSTANTS';
|
||||
import DownloadIcon from 'components/DownloadIcon';
|
||||
|
||||
const QueueItem = styled.View<{ active?: boolean, alreadyPlayed?: boolean, isDark?: boolean }>`
|
||||
padding: 10px;
|
||||
border-bottom-width: 1px;
|
||||
flex: 0 0 auto;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
${props => props.active && css`
|
||||
font-weight: 900;
|
||||
@@ -62,8 +66,13 @@ export default function Queue() {
|
||||
currentIndex === i ? defaultStyles.activeBackground : {},
|
||||
]}
|
||||
>
|
||||
<Text style={currentIndex === i ? { color: THEME_COLOR, fontWeight: '700' } : styles.trackTitle}>{track.title}</Text>
|
||||
<Text style={currentIndex === i ? { color: THEME_COLOR, fontWeight: '400' } : defaultStyles.textHalfOpacity}>{track.artist}</Text>
|
||||
<View>
|
||||
<Text style={currentIndex === i ? { color: THEME_COLOR, fontWeight: '700' } : styles.trackTitle}>{track.title}</Text>
|
||||
<Text style={currentIndex === i ? { color: THEME_COLOR, fontWeight: '400' } : defaultStyles.textHalfOpacity}>{track.artist}</Text>
|
||||
</View>
|
||||
<View style={{ marginLeft: 'auto' }}>
|
||||
<DownloadIcon trackId={track.backendId} />
|
||||
</View>
|
||||
</QueueItem>
|
||||
</TouchableHandler>
|
||||
))}
|
||||
|
||||
@@ -2,17 +2,21 @@ import React from 'react';
|
||||
import { createBottomTabNavigator, BottomTabNavigationProp } from '@react-navigation/bottom-tabs';
|
||||
import { createStackNavigator, StackNavigationProp } from '@react-navigation/stack';
|
||||
import { CompositeNavigationProp } from '@react-navigation/native';
|
||||
import SetJellyfinServer from './modals/SetJellyfinServer';
|
||||
import { THEME_COLOR } from 'CONSTANTS';
|
||||
|
||||
import Player from './Player';
|
||||
import Music from './Music';
|
||||
import Settings from './Settings';
|
||||
import Downloads from './Downloads';
|
||||
import Onboarding from './Onboarding';
|
||||
import TrackPopupMenu from './modals/TrackPopupMenu';
|
||||
import SetJellyfinServer from './modals/SetJellyfinServer';
|
||||
|
||||
import PlayPauseIcon from 'assets/play-pause-fill.svg';
|
||||
import NotesIcon from 'assets/notes.svg';
|
||||
import GearIcon from 'assets/gear.svg';
|
||||
import { THEME_COLOR } from 'CONSTANTS';
|
||||
import DownloadsIcon from 'assets/arrow-down-to-line.svg';
|
||||
import { useTypedSelector } from 'store';
|
||||
import Onboarding from './Onboarding';
|
||||
import TrackPopupMenu from './modals/TrackPopupMenu';
|
||||
import { ModalStackParams } from './types';
|
||||
import { t } from '@localisation';
|
||||
import ErrorReportingAlert from 'utility/ErrorReportingAlert';
|
||||
@@ -35,6 +39,8 @@ function getIcon(route: string): React.FC<any> | null {
|
||||
return NotesIcon;
|
||||
case 'Settings':
|
||||
return GearIcon;
|
||||
case 'Downloads':
|
||||
return DownloadsIcon;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -70,6 +76,7 @@ function Screens() {
|
||||
>
|
||||
<Tab.Screen name="NowPlaying" component={Player} options={{ tabBarLabel: t('now-playing') }} />
|
||||
<Tab.Screen name="Music" component={Music} options={{ tabBarLabel: t('music') }} />
|
||||
<Tab.Screen name="Downloads" component={Downloads} options={{ tabBarLabel: t('downloads')}} />
|
||||
<Tab.Screen name="Settings" component={Settings} options={{ tabBarLabel: t('settings') }} />
|
||||
</Tab.Navigator>
|
||||
</>
|
||||
|
||||
@@ -5,12 +5,17 @@ import { ModalStackParams } from 'screens/types';
|
||||
import { useTypedSelector } from 'store';
|
||||
import { SubHeader } from 'components/Typography';
|
||||
import styled from 'styled-components/native';
|
||||
import usePlayTrack from 'utility/usePlayTrack';
|
||||
import { t } from '@localisation';
|
||||
import PlayIcon from 'assets/play.svg';
|
||||
import DownloadIcon from 'assets/cloud-down-arrow.svg';
|
||||
import QueueAppendIcon from 'assets/queue-append.svg';
|
||||
import TrashIcon from 'assets/trash.svg';
|
||||
import Text from 'components/Text';
|
||||
import { WrappableButton, WrappableButtonRow } from 'components/WrappableButtonRow';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { downloadTrack, removeDownloadedTrack } from 'store/downloads/actions';
|
||||
import usePlayTracks from 'utility/usePlayTracks';
|
||||
import { selectIsDownloaded } from 'store/downloads/selectors';
|
||||
|
||||
type Route = RouteProp<ModalStackParams, 'TrackPopupMenu'>;
|
||||
|
||||
@@ -21,25 +26,46 @@ const Container = styled.View`
|
||||
`;
|
||||
|
||||
function TrackPopupMenu() {
|
||||
// Retrieve helpers
|
||||
// Retrieve trackId from route
|
||||
const { params: { trackId } } = useRoute<Route>();
|
||||
|
||||
// Retrieve helpers
|
||||
const navigation = useNavigation();
|
||||
const dispatch = useDispatch();
|
||||
const playTracks = usePlayTracks();
|
||||
|
||||
// Retrieve data from store
|
||||
const track = useTypedSelector((state) => state.music.tracks.entities[trackId]);
|
||||
const playTrack = usePlayTrack();
|
||||
const isDownloaded = useTypedSelector(selectIsDownloaded(trackId));
|
||||
|
||||
// Set callback to close the modal
|
||||
const closeModal = useCallback(() => {
|
||||
navigation.dispatch(StackActions.popToTop());
|
||||
}, [navigation]);
|
||||
|
||||
// Callback for adding the track to the queue as the next song
|
||||
const handlePlayNext = useCallback(() => {
|
||||
playTrack(trackId, false, false);
|
||||
playTracks([trackId], { method: 'add-after-currently-playing', play: false });
|
||||
closeModal();
|
||||
}, [playTrack, closeModal, trackId]);
|
||||
}, [playTracks, closeModal, trackId]);
|
||||
|
||||
// Callback for adding the track to the end of the queue
|
||||
const handleAddToQueue = useCallback(() => {
|
||||
playTrack(trackId, false, true);
|
||||
playTracks([trackId], { method: 'add-to-end', play: false });
|
||||
closeModal();
|
||||
}, [playTrack, closeModal, trackId]);
|
||||
}, [playTracks, closeModal, trackId]);
|
||||
|
||||
// Callback for downloading the track
|
||||
const handleDownload = useCallback(() => {
|
||||
dispatch(downloadTrack(trackId));
|
||||
closeModal();
|
||||
}, [trackId, dispatch, closeModal]);
|
||||
|
||||
// Callback for removing the downloaded track
|
||||
const handleDelete = useCallback(() => {
|
||||
dispatch(removeDownloadedTrack(trackId));
|
||||
closeModal();
|
||||
}, [trackId, dispatch, closeModal]);
|
||||
|
||||
return (
|
||||
<Modal fullSize={false}>
|
||||
@@ -49,6 +75,11 @@ function TrackPopupMenu() {
|
||||
<WrappableButtonRow>
|
||||
<WrappableButton title={t('play-next')} icon={PlayIcon} onPress={handlePlayNext} />
|
||||
<WrappableButton title={t('add-to-queue')} icon={QueueAppendIcon} onPress={handleAddToQueue} />
|
||||
{isDownloaded ? (
|
||||
<WrappableButton title={t('delete-track')} icon={TrashIcon} onPress={handleDelete} />
|
||||
) : (
|
||||
<WrappableButton title={t('download-track')} icon={DownloadIcon} onPress={handleDownload} />
|
||||
)}
|
||||
</WrappableButtonRow>
|
||||
</Container>
|
||||
</Modal>
|
||||
|
||||
54
src/store/downloads/actions.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { createAction, createAsyncThunk, createEntityAdapter, EntityId } from '@reduxjs/toolkit';
|
||||
import { AppState } from 'store';
|
||||
import { generateTrackUrl } from 'utility/JellyfinApi';
|
||||
import { downloadFile, unlink, DocumentDirectoryPath } from 'react-native-fs';
|
||||
import { DownloadEntity } from './types';
|
||||
|
||||
export const downloadAdapter = createEntityAdapter<DownloadEntity>({
|
||||
selectId: (entity) => entity.id,
|
||||
});
|
||||
|
||||
export const initializeDownload = createAction<{ id: EntityId, size?: number, jobId?: number }>('download/initialize');
|
||||
export const progressDownload = createAction<{ id: EntityId, progress: number, jobId?: number }>('download/progress');
|
||||
export const completeDownload = createAction<{ id: EntityId, location: string, size?: number }>('download/complete');
|
||||
export const failDownload = createAction<{ id: EntityId }>('download/fail');
|
||||
|
||||
export const downloadTrack = createAsyncThunk(
|
||||
'/downloads/track',
|
||||
async (id: EntityId, { dispatch, getState }) => {
|
||||
// Get the credentials from the store
|
||||
const { settings: { jellyfin: credentials } } = (getState() as AppState);
|
||||
|
||||
// Generate the URL we can use to download the file
|
||||
const url = generateTrackUrl(id as string, credentials);
|
||||
const location = `${DocumentDirectoryPath}/${id}.mp3`;
|
||||
|
||||
// Actually kick off the download
|
||||
const { promise } = await downloadFile({
|
||||
fromUrl: url,
|
||||
progressInterval: 250,
|
||||
background: true,
|
||||
begin: ({ jobId, contentLength }) => {
|
||||
// Dispatch the initialization
|
||||
dispatch(initializeDownload({ id, jobId, size: contentLength }));
|
||||
},
|
||||
progress: (result) => {
|
||||
// Dispatch a progress update
|
||||
dispatch(progressDownload({ id, progress: result.bytesWritten / result.contentLength }));
|
||||
},
|
||||
toFile: location,
|
||||
});
|
||||
|
||||
// Await job completion
|
||||
const result = await promise;
|
||||
dispatch(completeDownload({ id, location, size: result.bytesWritten }));
|
||||
},
|
||||
);
|
||||
|
||||
export const removeDownloadedTrack = createAsyncThunk(
|
||||
'/downloads/track/remove',
|
||||
async(id: EntityId) => {
|
||||
return unlink(`${DocumentDirectoryPath}/${id}.mp3`);
|
||||
}
|
||||
);
|
||||
|
||||
60
src/store/downloads/index.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { createSlice, Dictionary, EntityId } from '@reduxjs/toolkit';
|
||||
import { completeDownload, downloadAdapter, failDownload, initializeDownload, progressDownload, removeDownloadedTrack } from './actions';
|
||||
import { DownloadEntity } from './types';
|
||||
|
||||
interface State {
|
||||
entities: Dictionary<DownloadEntity>;
|
||||
ids: EntityId[];
|
||||
}
|
||||
|
||||
const initialState: State = {
|
||||
entities: {},
|
||||
ids: [],
|
||||
};
|
||||
|
||||
const downloads = createSlice({
|
||||
name: 'downloads',
|
||||
initialState,
|
||||
reducers: {},
|
||||
extraReducers: builder => {
|
||||
builder.addCase(initializeDownload, (state, action) => {
|
||||
downloadAdapter.upsertOne(state, {
|
||||
...action.payload,
|
||||
progress: 0,
|
||||
isFailed: false,
|
||||
isComplete: false,
|
||||
});
|
||||
});
|
||||
builder.addCase(progressDownload, (state, action) => {
|
||||
downloadAdapter.updateOne(state, {
|
||||
id: action.payload.id,
|
||||
changes: action.payload
|
||||
});
|
||||
});
|
||||
builder.addCase(completeDownload, (state, action) => {
|
||||
downloadAdapter.updateOne(state, {
|
||||
id: action.payload.id,
|
||||
changes: {
|
||||
...action.payload,
|
||||
isFailed: false,
|
||||
isComplete: true,
|
||||
}
|
||||
});
|
||||
});
|
||||
builder.addCase(failDownload, (state, action) => {
|
||||
downloadAdapter.updateOne(state, {
|
||||
id: action.payload.id,
|
||||
changes: {
|
||||
isComplete: false,
|
||||
isFailed: true,
|
||||
progress: 0,
|
||||
}
|
||||
});
|
||||
});
|
||||
builder.addCase(removeDownloadedTrack.fulfilled, (state, action) => {
|
||||
downloadAdapter.removeOne(state, action.meta.arg);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export default downloads;
|
||||
29
src/store/downloads/selectors.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { createSelector, EntityId } from '@reduxjs/toolkit';
|
||||
import { intersection } from 'lodash';
|
||||
import { AppState } from 'store';
|
||||
|
||||
export const selectAllDownloads = (state: AppState) => state.downloads;
|
||||
export const selectDownloadedEntities = (state: AppState) => state.downloads.entities;
|
||||
|
||||
/**
|
||||
* Only retain the supplied trackIds that have successfully been downloaded
|
||||
*/
|
||||
export const selectDownloadedTracks = (trackIds: EntityId[]) => (
|
||||
createSelector(
|
||||
selectAllDownloads,
|
||||
({ entities, ids }) => {
|
||||
return intersection(trackIds, ids)
|
||||
.filter((id) => entities[id]?.isComplete);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* Select a boolean that indicates whether the track is downloaded
|
||||
*/
|
||||
export const selectIsDownloaded = (trackId: string) => (
|
||||
createSelector(
|
||||
selectDownloadedEntities,
|
||||
(entities) => entities[trackId]?.isComplete,
|
||||
)
|
||||
);
|
||||
11
src/store/downloads/types.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { EntityId } from '@reduxjs/toolkit';
|
||||
|
||||
export interface DownloadEntity {
|
||||
id: EntityId;
|
||||
progress: number;
|
||||
isFailed: boolean;
|
||||
isComplete: boolean;
|
||||
size?: number;
|
||||
location?: string;
|
||||
jobId?: number;
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import { useSelector, TypedUseSelectorHook, useDispatch } from 'react-redux';
|
||||
import AsyncStorage from '@react-native-community/async-storage';
|
||||
import { persistStore, persistReducer, PersistConfig } from 'redux-persist';
|
||||
import autoMergeLevel2 from 'redux-persist/es/stateReconciler/autoMergeLevel2';
|
||||
// import logger from 'redux-logger';
|
||||
|
||||
const persistConfig: PersistConfig<AppState> = {
|
||||
key: 'root',
|
||||
@@ -13,10 +12,12 @@ const persistConfig: PersistConfig<AppState> = {
|
||||
|
||||
import settings from './settings';
|
||||
import music from './music';
|
||||
import downloads from './downloads';
|
||||
|
||||
const reducers = combineReducers({
|
||||
settings,
|
||||
music: music.reducer,
|
||||
downloads: downloads.reducer,
|
||||
});
|
||||
|
||||
const persistedReducer = persistReducer(persistConfig, reducers);
|
||||
@@ -24,7 +25,8 @@ const persistedReducer = persistReducer(persistConfig, reducers);
|
||||
const store = configureStore({
|
||||
reducer: persistedReducer,
|
||||
middleware: getDefaultMiddleware({ serializableCheck: false, immutableCheck: false }).concat(
|
||||
// logger
|
||||
// logger,
|
||||
__DEV__ ? require('redux-flipper').default() : undefined,
|
||||
),
|
||||
});
|
||||
|
||||
|
||||
@@ -20,8 +20,8 @@ const baseTrackOptions: Record<string, string> = {
|
||||
MaxStreamingBitrate: '140000000',
|
||||
MaxSampleRate: '48000',
|
||||
// This must be set to support client seeking
|
||||
TranscodingProtocol: 'hls',
|
||||
TranscodingContainer: 'ts',
|
||||
TranscodingProtocol: 'http',
|
||||
TranscodingContainer: 'aac',
|
||||
Container: 'mp3,aac,m4a,m4b|aac,alac,m4a,m4b|alac,flac|ogg',
|
||||
AudioCodec: 'aac',
|
||||
static: 'true',
|
||||
@@ -34,14 +34,7 @@ const baseTrackOptions: Record<string, string> = {
|
||||
*/
|
||||
export function generateTrack(track: AlbumTrack, credentials: Credentials): Track {
|
||||
// Also construct the URL for the stream
|
||||
const trackOptions = {
|
||||
...baseTrackOptions,
|
||||
UserId: credentials?.user_id || '',
|
||||
api_key: credentials?.access_token || '',
|
||||
DeviceId: credentials?.device_id || '',
|
||||
};
|
||||
const trackParams = new URLSearchParams(trackOptions).toString();
|
||||
const url = encodeURI(`${credentials?.uri}/Audio/${track.Id}/universal?${trackParams}`);
|
||||
const url = generateTrackUrl(track.Id, credentials);
|
||||
|
||||
return {
|
||||
url,
|
||||
@@ -55,6 +48,23 @@ export function generateTrack(track: AlbumTrack, credentials: Credentials): Trac
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the track streaming url from the trackId
|
||||
*/
|
||||
export function generateTrackUrl(trackId: string, credentials: Credentials) {
|
||||
const trackOptions = {
|
||||
...baseTrackOptions,
|
||||
UserId: credentials?.user_id || '',
|
||||
api_key: credentials?.access_token || '',
|
||||
DeviceId: credentials?.device_id || '',
|
||||
};
|
||||
|
||||
const trackParams = new URLSearchParams(trackOptions).toString();
|
||||
const url = encodeURI(`${credentials?.uri}/Audio/${trackId}/universal?${trackParams}`);
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
const albumOptions = {
|
||||
SortBy: 'AlbumArtist,SortName',
|
||||
SortOrder: 'Ascending',
|
||||
|
||||
15
src/utility/formatBytes.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||
|
||||
/**
|
||||
* Convert a number of bytes to a human-readable string
|
||||
* CREDIT: https://gist.github.com/zentala/1e6f72438796d74531803cc3833c039c
|
||||
*/
|
||||
export default function formatBytes(bytes: number, decimals: number = 2) {
|
||||
if (bytes === 0) {
|
||||
return '0 Bytes';
|
||||
}
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + ' ' + sizes[i];
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import TrackPlayer from 'react-native-track-player';
|
||||
import { useTypedSelector } from 'store';
|
||||
import { generateTrack } from './JellyfinApi';
|
||||
import useQueue from './useQueue';
|
||||
|
||||
/**
|
||||
* A hook that generates a callback that can setup and start playing a
|
||||
* particular trackId in the player.
|
||||
*/
|
||||
export default function usePlayTrack() {
|
||||
const credentials = useTypedSelector(state => state.settings.jellyfin);
|
||||
const tracks = useTypedSelector(state => state.music.tracks.entities);
|
||||
const queue = useQueue();
|
||||
|
||||
return useCallback(async function playTrack(trackId: string, play: boolean = true, addToEnd: boolean = true) {
|
||||
// Get the relevant track
|
||||
const track = tracks[trackId];
|
||||
|
||||
// GUARD: Check if the track actually exists in the store
|
||||
if (!track) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate the new track for the queue
|
||||
const newTrack = generateTrack(track, credentials);
|
||||
|
||||
// Then, we'll need to check where to add the track
|
||||
if (addToEnd) {
|
||||
await TrackPlayer.add([ newTrack ]);
|
||||
|
||||
// Then we'll skip to it and play it
|
||||
if (play) {
|
||||
await TrackPlayer.skip(await (await TrackPlayer.getQueue()).length);
|
||||
await TrackPlayer.play();
|
||||
}
|
||||
} else {
|
||||
// Try and locate the current track
|
||||
const currentTrackIndex = await TrackPlayer.getCurrentTrack();
|
||||
|
||||
// Since the argument is the id to insert the track BEFORE, we need
|
||||
// to get the current track + 1
|
||||
const targetTrack = currentTrackIndex >= 0 && queue.length > 1
|
||||
? queue[currentTrackIndex + 1].id
|
||||
: undefined;
|
||||
|
||||
// Depending on whether this track exists, we either add it there,
|
||||
// or at the end of the queue.
|
||||
await TrackPlayer.add([ newTrack ], targetTrack);
|
||||
|
||||
if (play) {
|
||||
await TrackPlayer.skip(currentTrackIndex + 1);
|
||||
await TrackPlayer.play();
|
||||
}
|
||||
}
|
||||
|
||||
return newTrack;
|
||||
}, [credentials, tracks, queue]);
|
||||
}
|
||||
@@ -5,6 +5,18 @@ import { generateTrack } from './JellyfinApi';
|
||||
import { EntityId } from '@reduxjs/toolkit';
|
||||
import { shuffle as shuffleArray } from 'lodash';
|
||||
|
||||
interface PlayOptions {
|
||||
play: boolean;
|
||||
shuffle: boolean;
|
||||
method: 'add-to-end' | 'add-after-currently-playing' | 'replace';
|
||||
}
|
||||
|
||||
const defaults: PlayOptions = {
|
||||
play: true,
|
||||
shuffle: false,
|
||||
method: 'replace',
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a callback function that starts playing a full album given its
|
||||
* supplied id.
|
||||
@@ -12,36 +24,92 @@ import { shuffle as shuffleArray } from 'lodash';
|
||||
export default function usePlayTracks() {
|
||||
const credentials = useTypedSelector(state => state.settings.jellyfin);
|
||||
const tracks = useTypedSelector(state => state.music.tracks.entities);
|
||||
const downloads = useTypedSelector(state => state.downloads.entities);
|
||||
|
||||
return useCallback(async function playTracks(
|
||||
trackIds: EntityId[] | undefined,
|
||||
play: boolean = true,
|
||||
shuffle: boolean = false,
|
||||
options: Partial<PlayOptions> = {},
|
||||
): Promise<Track[] | undefined> {
|
||||
if (!trackIds) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Retrieve options and queue
|
||||
const {
|
||||
play,
|
||||
shuffle,
|
||||
method,
|
||||
} = Object.assign({}, defaults, options);
|
||||
const queue = await TrackPlayer.getQueue();
|
||||
|
||||
// Convert all trackIds to the relevant format for react-native-track-player
|
||||
const newTracks = trackIds.map((trackId) => {
|
||||
const generatedTracks = trackIds.map((trackId) => {
|
||||
const track = tracks[trackId];
|
||||
|
||||
// GUARD: Check that the track actually exists in Redux
|
||||
if (!trackId || !track) {
|
||||
return;
|
||||
}
|
||||
|
||||
return generateTrack(track, credentials);
|
||||
// Retrieve the generated track from Jellyfin
|
||||
const generatedTrack = generateTrack(track, credentials);
|
||||
|
||||
// Check if a downloaded version exists, and if so rewrite the URL
|
||||
const download = downloads[trackId];
|
||||
if (download?.location) {
|
||||
generatedTrack.url = download.location;
|
||||
}
|
||||
|
||||
return generatedTrack;
|
||||
}).filter((t): t is Track => typeof t !== 'undefined');
|
||||
|
||||
// Clear the queue and add all tracks
|
||||
await TrackPlayer.reset();
|
||||
await TrackPlayer.add(shuffle ? shuffleArray(newTracks) : newTracks);
|
||||
// Potentially shuffle all tracks
|
||||
const newTracks = shuffle ? shuffleArray(generatedTracks) : generatedTracks;
|
||||
|
||||
// Play the queue
|
||||
if (play) {
|
||||
await TrackPlayer.play();
|
||||
// Then, we'll need to check where to add the track
|
||||
switch(method) {
|
||||
case 'add-to-end': {
|
||||
await TrackPlayer.add(newTracks);
|
||||
|
||||
// Then we'll skip to it and play it
|
||||
if (play) {
|
||||
await TrackPlayer.skip((await TrackPlayer.getQueue()).length - newTracks.length);
|
||||
await TrackPlayer.play();
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case 'add-after-currently-playing': {
|
||||
// Try and locate the current track
|
||||
const currentTrackIndex = await TrackPlayer.getCurrentTrack();
|
||||
|
||||
// Since the argument is the id to insert the track BEFORE, we need
|
||||
// to get the current track + 1
|
||||
const targetTrack = currentTrackIndex >= 0 && queue.length > 1
|
||||
? queue[currentTrackIndex + 1].id
|
||||
: undefined;
|
||||
|
||||
// Depending on whether this track exists, we either add it there,
|
||||
// or at the end of the queue.
|
||||
await TrackPlayer.add(newTracks, targetTrack);
|
||||
|
||||
if (play) {
|
||||
await TrackPlayer.skip(currentTrackIndex + 1);
|
||||
await TrackPlayer.play();
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case 'replace': {
|
||||
await TrackPlayer.reset();
|
||||
await TrackPlayer.add(newTracks);
|
||||
|
||||
if (play) {
|
||||
await TrackPlayer.play();
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return newTracks;
|
||||
}, [credentials, tracks]);
|
||||
}, [credentials, downloads, tracks]);
|
||||
}
|
||||