Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10d4b3088f | ||
|
|
a7f1c8a13c | ||
|
|
516bf3a689 | ||
|
|
141deb8da0 | ||
|
|
4bcdb01029 | ||
|
|
3b51249341 | ||
|
|
0585c9da21 | ||
|
|
7ab8dfa3c3 | ||
|
|
8d0d220d78 | ||
|
|
3e0379996c | ||
|
|
6aecaaa44a | ||
|
|
21627f7a97 | ||
|
|
cb8e0bfa79 | ||
|
|
19601e87c7 | ||
|
|
3945aa7b58 | ||
|
|
b455db4bdf | ||
|
|
38a447f57a | ||
|
|
8a24157832 | ||
|
|
166654d374 | ||
|
|
6e8a1a373e | ||
|
|
b1bed439eb | ||
|
|
2562d3d4b6 | ||
|
|
c27d9d09e4 | ||
|
|
3cb8b522f6 | ||
|
|
9115db5e15 | ||
|
|
d63c10482c | ||
|
|
bd283dd039 | ||
|
|
0403acc9d3 | ||
|
|
b81eee7f34 | ||
|
|
8cef2d9cb0 | ||
|
|
2e042922f4 | ||
|
|
79fb716551 | ||
|
|
37b0cf65b2 | ||
|
|
b8a7d8610c | ||
|
|
ad3f26de30 | ||
|
|
f23ab5b96e | ||
|
|
f4c52f4069 | ||
|
|
9af87d2e02 | ||
|
|
adbf126430 | ||
|
|
61817ed14e | ||
|
|
0eff03a66e | ||
|
|
6417a37366 | ||
|
|
e519304f2e | ||
|
|
d0bfcb107d | ||
|
|
2f307ee613 | ||
|
|
12c0d65003 | ||
|
|
049a63b079 | ||
|
|
e7a4220765 | ||
|
|
3030bac32b | ||
|
|
1e12897c8a | ||
|
|
79a355c768 | ||
|
|
9501831691 | ||
|
|
a6927f0edf | ||
|
|
a952ab0a00 | ||
|
|
0c23de9ede | ||
|
|
080907c813 | ||
|
|
b5e01fc8c2 | ||
|
|
f25686d542 | ||
|
|
4f5531b67c | ||
|
|
860b8bba49 | ||
|
|
e23f248427 | ||
|
|
c6bd1ef867 | ||
|
|
931bc9b931 | ||
|
|
3f4d5c2923 | ||
|
|
1220dca88e | ||
|
|
d10b882516 | ||
|
|
be69d16948 | ||
|
|
8f77b5226d | ||
|
|
8ee2b7cfe1 | ||
|
|
f14ed7d762 | ||
|
|
2324590175 | ||
|
|
ed478d1ed0 | ||
|
|
efa7f1f724 | ||
|
|
449c5b055f | ||
|
|
a38513db56 | ||
|
|
b06609a829 | ||
|
|
c9b4a08072 | ||
|
|
e6b699c976 | ||
|
|
831552d5b8 | ||
|
|
9bb9879dc4 | ||
|
|
0101dbde71 | ||
|
|
a618218828 | ||
|
|
173ab59007 | ||
|
|
0b9998ddff | ||
|
|
415cb90af2 | ||
|
|
0da4b11f17 | ||
|
|
6c41e09214 | ||
|
|
d37195e01e | ||
|
|
ed7c47b3db | ||
|
|
7dbe5db11c | ||
|
|
44a20e9e33 | ||
|
|
6d3b686524 | ||
|
|
855a70e91e | ||
|
|
d2bd3b8ae6 | ||
|
|
30461f3668 | ||
|
|
92fa64cfc7 | ||
|
|
ce5e5d7f94 | ||
|
|
fd64f26c5e | ||
|
|
aeeacfc84b | ||
|
|
95a15a07cc | ||
|
|
3366ed02f5 | ||
|
|
6cfc939941 | ||
|
|
29bfb70cc9 | ||
|
|
fd0f3f2241 | ||
|
|
b25ab0da8d | ||
|
|
9be219ce15 | ||
|
|
e86f093470 | ||
|
|
5fbdbe3595 | ||
|
|
0d5873171b | ||
|
|
4845d2e659 | ||
|
|
01857a1c05 | ||
|
|
e01ac6d03b | ||
|
|
12192c96c7 | ||
|
|
bc741a1310 | ||
|
|
0cbb5d6e8b | ||
|
|
b9c5d827bb | ||
|
|
3cfb676b55 | ||
|
|
ecfd4ca1b8 | ||
|
|
d2ebc2b165 | ||
|
|
3853ffb1aa | ||
|
|
9514bbb5d6 | ||
|
|
fdad9549b6 | ||
|
|
6e06244b1c | ||
|
|
57ca53b9a6 | ||
|
|
a878a9a512 | ||
|
|
94bf264b07 | ||
|
|
f828ee926b | ||
|
|
0c102ff3a3 | ||
|
|
995f0d006d | ||
|
|
a607904a35 | ||
|
|
7fbd320fa7 | ||
|
|
1043db2fb8 | ||
|
|
41194f2807 | ||
|
|
bbec52e5f0 | ||
|
|
349b28d25e | ||
|
|
15dd8f813a | ||
|
|
cb4a446509 | ||
|
|
b98374b32f | ||
|
|
0ca75fb305 | ||
|
|
2d706956da | ||
|
|
260681aa24 | ||
|
|
6228eb2690 | ||
|
|
0d4f7a9e9f | ||
|
|
fac7aed1e7 | ||
|
|
b6234120dd | ||
|
|
2eb33eb234 | ||
|
|
d744e783ac | ||
|
|
3e068b2bea | ||
|
|
36a843b43f | ||
|
|
3f40007f03 | ||
|
|
de63b9466f | ||
|
|
66941b06d4 | ||
|
|
47f5454f61 | ||
|
|
2a6098c3ba | ||
|
|
60a84380a6 | ||
|
|
e047c820ae | ||
|
|
e4e7911573 | ||
|
|
7fcf79045b | ||
|
|
40b3142d23 | ||
|
|
8f18a7c941 | ||
|
|
07b4ba371e | ||
|
|
2571a6c38b | ||
|
|
57b691782c | ||
|
|
eb8968c428 | ||
|
|
8b6052d97b | ||
|
|
155ac2042e | ||
|
|
1c7fccdd6b | ||
|
|
c15bc6d4c2 | ||
|
|
4888d125e4 | ||
|
|
1c8d36f0e9 | ||
|
|
6e58f26ee4 | ||
|
|
b30c9c6420 | ||
|
|
cef1e38f6d | ||
|
|
00c02730d7 | ||
|
|
bb97e5a418 | ||
|
|
d51c3ff607 | ||
|
|
e500fe452d | ||
|
|
fd9ba46b7b | ||
|
|
00da8a23e3 | ||
|
|
0817153f4b | ||
|
|
c559fbf89c | ||
|
|
844965e051 | ||
|
|
60ba1b1570 | ||
|
|
36c6adb50c | ||
|
|
4180ab6248 | ||
|
|
597640ece5 | ||
|
|
4031b6043c | ||
|
|
57676178e2 | ||
|
|
4430581806 | ||
|
|
7236702818 | ||
|
|
0774263d83 | ||
|
|
6243195b21 | ||
|
|
8d8423d136 | ||
|
|
2f7c1084cb | ||
|
|
03495b1ea3 | ||
|
|
a48032c706 | ||
|
|
357a4a5590 | ||
|
|
81adf86769 | ||
|
|
50264d52f3 | ||
|
|
2603a3992c | ||
|
|
3f95b4d822 | ||
|
|
3b8c944512 | ||
|
|
d646adca89 | ||
|
|
3e7a9231b7 | ||
|
|
dd76e59bf1 | ||
|
|
bc54a2230b | ||
|
|
4b972b0992 | ||
|
|
1cab790570 | ||
|
|
feb9254aea | ||
|
|
9a2e5e5358 | ||
|
|
e260f801d6 | ||
|
|
2ac3555567 | ||
|
|
dafdea282e | ||
|
|
aa1efdff64 | ||
|
|
3d5c7f7e3b | ||
|
|
1ec9fbcc02 | ||
|
|
8e00ad0551 | ||
|
|
deadbd1be7 | ||
|
|
3d3358e4f9 | ||
|
|
51cb83cef9 | ||
|
|
0e2fe8a9f3 | ||
|
|
63fa08798c | ||
|
|
94e6c4109a | ||
|
|
4dd2d7b011 | ||
|
|
4696b5c7b0 | ||
|
|
852cee8967 | ||
|
|
2e7babaae5 | ||
|
|
65057750f7 | ||
|
|
e50bd46386 | ||
|
|
1c67fa3998 | ||
|
|
d586c8c067 | ||
|
|
f1a5493aeb | ||
|
|
7efa96be74 | ||
|
|
770cb6b744 | ||
|
|
5126b67c9c | ||
|
|
f0035d1335 | ||
|
|
ca576110b8 | ||
|
|
1f59374f62 | ||
|
|
679e5d5c39 | ||
|
|
73568ee3ae | ||
|
|
aa9dd045de | ||
|
|
69e7e796b4 | ||
|
|
95263b7625 | ||
|
|
236e3e7cfc | ||
|
|
f06deba0cc | ||
|
|
dcb4f3ee8a | ||
|
|
4246d7689d | ||
|
|
781aa4640e | ||
|
|
ed668ba0e4 | ||
|
|
fffe342dd1 | ||
|
|
b5349d7b3d | ||
|
|
f7d98fbe44 | ||
|
|
3a5b812a22 | ||
|
|
b01f9ce3f9 | ||
|
|
25ff8f1d1f | ||
|
|
ce8d384e7c | ||
|
|
bae7b70feb |
@@ -0,0 +1,101 @@
|
||||
name: Bug Report
|
||||
description: Report a bug or unexpected behavior
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to report a bug! Please fill out the information below to help us diagnose and fix the issue.
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Bug Description
|
||||
description: A clear and concise description of what the bug is.
|
||||
placeholder: What went wrong?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: Steps to reproduce the behavior
|
||||
placeholder: |
|
||||
1. Go to '...'
|
||||
2. Tap on '...'
|
||||
3. Scroll down to '...'
|
||||
4. See error
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: What did you expect to happen?
|
||||
placeholder: Describe what should have happened
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual Behavior
|
||||
description: What actually happened?
|
||||
placeholder: Describe what actually happened
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: plezy-version
|
||||
attributes:
|
||||
label: Plezy Version
|
||||
description: Check the app settings for the version number and paste it here
|
||||
placeholder: e.g., 1.7.0
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: platform
|
||||
attributes:
|
||||
label: Platform
|
||||
description: Which platform(s) are affected?
|
||||
multiple: true
|
||||
options:
|
||||
- Android
|
||||
- iOS
|
||||
- Web
|
||||
- macOS
|
||||
- Windows
|
||||
- Linux
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: device
|
||||
attributes:
|
||||
label: Device Information
|
||||
description: What device are you using?
|
||||
placeholder: e.g., iPhone 14 Pro, Pixel 7, Chrome on macOS
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs and Stack Traces
|
||||
description: Please paste any relevant logs or stack traces
|
||||
render: shell
|
||||
placeholder: Paste logs here
|
||||
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: Screenshots
|
||||
description: If applicable, add screenshots to help explain your problem
|
||||
placeholder: Drag and drop screenshots here
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any other context about the problem here
|
||||
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Questions and Discussions
|
||||
url: https://github.com/edde746/plezy/discussions
|
||||
about: Ask questions and discuss ideas with the community
|
||||
@@ -0,0 +1,65 @@
|
||||
name: Feature Request
|
||||
description: Suggest a new feature or enhancement
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for suggesting a new feature! Please provide as much detail as possible.
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem Statement
|
||||
description: Is your feature request related to a problem? Please describe.
|
||||
placeholder: I'm frustrated when... / It would be helpful if...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Proposed Solution
|
||||
description: Describe the solution you'd like to see
|
||||
placeholder: What feature would solve this problem?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives Considered
|
||||
description: Have you considered any alternative solutions or features?
|
||||
placeholder: Describe alternatives you've considered
|
||||
|
||||
- type: textarea
|
||||
id: use-case
|
||||
attributes:
|
||||
label: Use Case
|
||||
description: Describe how you would use this feature
|
||||
placeholder: |
|
||||
1. As a user, I would...
|
||||
2. This would help me...
|
||||
3. The benefit would be...
|
||||
|
||||
- type: dropdown
|
||||
id: platform
|
||||
attributes:
|
||||
label: Target Platform
|
||||
description: Which platform(s) should this feature target?
|
||||
multiple: true
|
||||
options:
|
||||
- Android
|
||||
- iOS
|
||||
- Web
|
||||
- macOS
|
||||
- Windows
|
||||
- Linux
|
||||
- All platforms
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any other context, mockups, or screenshots about the feature request
|
||||
placeholder: Add mockups, links to similar features, or any other helpful context
|
||||
@@ -2,9 +2,6 @@ name: Build Flutter App
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
build-android:
|
||||
@@ -50,8 +47,22 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Configure Android signing
|
||||
run: |
|
||||
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode > android/app/upload-keystore.jks
|
||||
cat > android/key.properties << EOF
|
||||
storePassword=${{ secrets.ANDROID_STORE_PASSWORD }}
|
||||
keyPassword=${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
keyAlias=${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
storeFile=upload-keystore.jks
|
||||
EOF
|
||||
|
||||
- name: Build APK
|
||||
run: flutter build apk --release
|
||||
run: flutter build apk --release --dart-define=ENABLE_UPDATE_CHECK=true
|
||||
|
||||
- name: Clean up keystore
|
||||
if: always()
|
||||
run: rm -f android/app/upload-keystore.jks android/key.properties
|
||||
|
||||
- name: Rename APK
|
||||
run: |
|
||||
@@ -69,7 +80,7 @@ jobs:
|
||||
path: plezy-android.apk
|
||||
|
||||
build-ios:
|
||||
runs-on: macos-latest
|
||||
runs-on: macos-26
|
||||
permissions:
|
||||
id-token: write
|
||||
attestations: write
|
||||
@@ -107,7 +118,7 @@ jobs:
|
||||
run: flutter pub get
|
||||
|
||||
- name: Build iOS (no codesign)
|
||||
run: flutter build ios --release --no-codesign
|
||||
run: flutter build ios --release --no-codesign --dart-define=ENABLE_UPDATE_CHECK=true
|
||||
|
||||
- name: Create IPA
|
||||
run: |
|
||||
@@ -127,7 +138,7 @@ jobs:
|
||||
path: plezy-ios.ipa
|
||||
|
||||
build-macos:
|
||||
runs-on: macos-latest
|
||||
runs-on: macos-26
|
||||
permissions:
|
||||
id-token: write
|
||||
attestations: write
|
||||
@@ -165,12 +176,88 @@ jobs:
|
||||
run: flutter pub get
|
||||
|
||||
- name: Build macOS
|
||||
run: flutter build macos --release
|
||||
run: flutter build macos --release --dart-define=ENABLE_UPDATE_CHECK=true
|
||||
|
||||
- name: Import Code Signing Certificate
|
||||
env:
|
||||
MACOS_CERTIFICATE_BASE64: ${{ secrets.MACOS_CERTIFICATE_BASE64 }}
|
||||
MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
run: |
|
||||
# Create temporary keychain
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/build.keychain
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
|
||||
# Import certificate to keychain
|
||||
CERTIFICATE_PATH=$RUNNER_TEMP/certificate.p12
|
||||
echo "$MACOS_CERTIFICATE_BASE64" | base64 --decode -o $CERTIFICATE_PATH
|
||||
security import $CERTIFICATE_PATH -k $KEYCHAIN_PATH -P "$MACOS_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
|
||||
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
|
||||
# Add keychain to search list
|
||||
security list-keychains -d user -s $KEYCHAIN_PATH login.keychain
|
||||
|
||||
- name: Sign Application
|
||||
env:
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
# Find the identity
|
||||
IDENTITY=$(security find-identity -v -p codesigning | grep "Developer ID Application" | head -1 | grep -o '".*"' | tr -d '"')
|
||||
echo "Signing with identity: $IDENTITY"
|
||||
|
||||
APP_PATH="build/macos/Build/Products/Release/plezy.app"
|
||||
|
||||
# Sign all frameworks and dylibs first (inside-out signing)
|
||||
find "$APP_PATH/Contents/Frameworks" -name "*.framework" -o -name "*.dylib" | while read framework; do
|
||||
echo "Signing: $framework"
|
||||
codesign --force --sign "$IDENTITY" --timestamp --options runtime "$framework"
|
||||
done
|
||||
|
||||
# Sign the app bundle itself
|
||||
echo "Signing app bundle: $APP_PATH"
|
||||
codesign --force --sign "$IDENTITY" --timestamp --options runtime --entitlements macos/Runner/Release.entitlements "$APP_PATH"
|
||||
|
||||
- name: Verify Signature
|
||||
run: |
|
||||
APP_PATH="build/macos/Build/Products/Release/plezy.app"
|
||||
echo "Verifying signature..."
|
||||
codesign -dvvv "$APP_PATH"
|
||||
codesign --verify --deep --strict --verbose=2 "$APP_PATH"
|
||||
|
||||
- name: Notarize Application
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
APP_PATH="build/macos/Build/Products/Release/plezy.app"
|
||||
|
||||
# Create a temporary ZIP for notarization submission
|
||||
echo "Creating ZIP for notarization..."
|
||||
ditto -c -k --keepParent "$APP_PATH" notarization.zip
|
||||
|
||||
# Submit for notarization
|
||||
echo "Submitting to Apple's notary service..."
|
||||
xcrun notarytool submit notarization.zip \
|
||||
--apple-id "$APPLE_ID" \
|
||||
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
|
||||
--team-id "$APPLE_TEAM_ID" \
|
||||
--wait
|
||||
|
||||
# Staple the notarization ticket to the app
|
||||
echo "Stapling notarization ticket..."
|
||||
xcrun stapler staple "$APP_PATH"
|
||||
|
||||
# Verify stapling
|
||||
echo "Verifying notarization..."
|
||||
xcrun stapler validate "$APP_PATH"
|
||||
|
||||
- name: Create ZIP
|
||||
run: |
|
||||
cd build/macos/Build/Products/Release
|
||||
zip -r $GITHUB_WORKSPACE/plezy-macos.zip plezy.app
|
||||
ditto -c -k --sequesterRsrc --keepParent plezy.app $GITHUB_WORKSPACE/plezy-macos.zip
|
||||
|
||||
- name: Attest macOS ZIP
|
||||
uses: actions/attest-build-provenance@v2
|
||||
@@ -211,7 +298,7 @@ jobs:
|
||||
run: flutter pub get
|
||||
|
||||
- name: Build Windows
|
||||
run: flutter build windows --release
|
||||
run: flutter build windows --release --dart-define=ENABLE_UPDATE_CHECK=true
|
||||
|
||||
- name: Build Windows Installer
|
||||
run: .\windows\build-installer.ps1
|
||||
@@ -274,7 +361,7 @@ jobs:
|
||||
run: flutter pub get
|
||||
|
||||
- name: Build Linux
|
||||
run: flutter build linux --release
|
||||
run: flutter build linux --release --dart-define=ENABLE_UPDATE_CHECK=true
|
||||
|
||||
- name: Create Archive
|
||||
run: |
|
||||
@@ -293,7 +380,14 @@ jobs:
|
||||
path: plezy-linux.tar.gz
|
||||
|
||||
create-release:
|
||||
needs: [build-android, build-ios, build-macos, build-windows, build-linux]
|
||||
needs:
|
||||
[
|
||||
build-android,
|
||||
build-ios,
|
||||
build-macos,
|
||||
build-windows,
|
||||
build-linux,
|
||||
]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
name: CI - Sanity Checks
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Code Analysis
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
cache: true
|
||||
|
||||
- name: Cache Pub dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.pub-cache
|
||||
key: ${{ runner.os }}-pub-v2-${{ hashFiles('**/pubspec.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pub-v2-
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
|
||||
flutter pub get
|
||||
|
||||
- name: Verify formatting
|
||||
run: |
|
||||
# Find all Dart files excluding generated files
|
||||
find lib $([ -d test ] && echo test) -name "*.dart" ! -name "*.g.dart" ! -name "*.freezed.dart" -type f 2>/dev/null | while IFS= read -r file; do
|
||||
files_found=true
|
||||
break
|
||||
done
|
||||
if [ "$files_found" != "true" ]; then
|
||||
echo "No Dart files found to format"
|
||||
exit 0
|
||||
fi
|
||||
find lib $([ -d test ] && echo test) -name "*.dart" ! -name "*.g.dart" ! -name "*.freezed.dart" -type f 2>/dev/null -print0 | xargs -0 dart format --output=none --set-exit-if-changed
|
||||
- name: Analyze code
|
||||
run: |
|
||||
# Run flutter analyze
|
||||
# Fails on any errors or warnings (info messages are allowed)
|
||||
flutter analyze 2>&1 | tee analyze_output.txt
|
||||
|
||||
# Check the exit status
|
||||
if [ ${PIPESTATUS[0]} -ne 0 ]; then
|
||||
echo "❌ Analysis failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Also verify no warnings or errors in output
|
||||
if grep -q "error •" analyze_output.txt; then
|
||||
echo "❌ Analysis failed with errors"
|
||||
exit 1
|
||||
elif grep -q "warning •" analyze_output.txt; then
|
||||
echo "⚠️ Analysis completed with warnings"
|
||||
exit 1
|
||||
else
|
||||
echo "✅ Analysis passed - no issues found!"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
- name: Check for unused code
|
||||
run: |
|
||||
echo "🔍 Checking for unused code..."
|
||||
dart run dart_code_linter:metrics check-unused-code lib 2>&1 | tee unused_code.txt
|
||||
if grep -qi "no unused code found" unused_code.txt; then
|
||||
echo "✅ No unused code found"
|
||||
else
|
||||
echo "❌ Found unused code:"
|
||||
cat unused_code.txt
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Check for unused files
|
||||
run: |
|
||||
echo "🔍 Checking for unused files..."
|
||||
dart run dart_code_linter:metrics check-unused-files lib 2>&1 | tee unused_files.txt
|
||||
if grep -qi "no unused files found" unused_files.txt; then
|
||||
echo "✅ No unused files found"
|
||||
else
|
||||
echo "❌ Found unused files:"
|
||||
cat unused_files.txt
|
||||
exit 1
|
||||
fi
|
||||
|
||||
test:
|
||||
name: Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
cache: true
|
||||
|
||||
- name: Cache Pub dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.pub-cache
|
||||
key: ${{ runner.os }}-pub-v2-${{ hashFiles('**/pubspec.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pub-v2-
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
flutter clean
|
||||
flutter pub get
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
if [ -d "test" ] && [ "$(find test -name '*_test.dart' | wc -l)" -gt 0 ]; then
|
||||
flutter test
|
||||
else
|
||||
echo "No tests found, skipping test execution"
|
||||
fi
|
||||
|
||||
dependency-check:
|
||||
name: Dependency Validation
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
cache: true
|
||||
|
||||
- name: Cache Pub dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.pub-cache
|
||||
key: ${{ runner.os }}-pub-v2-${{ hashFiles('**/pubspec.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pub-v2-
|
||||
|
||||
- name: Verify dependencies
|
||||
run: |
|
||||
flutter clean
|
||||
flutter pub get
|
||||
flutter pub outdated
|
||||
@@ -0,0 +1,82 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version number (e.g., 1.9.0)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
actions: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Validate version format
|
||||
run: |
|
||||
if ! echo "${{ inputs.version }}" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "Error: Version must be in semantic versioning format (e.g., 1.9.0)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Extract current build number
|
||||
id: get_build
|
||||
run: |
|
||||
# Extract current build number from pubspec.yaml
|
||||
CURRENT_VERSION=$(grep '^version:' pubspec.yaml | sed 's/version: //')
|
||||
CURRENT_BUILD=$(echo "$CURRENT_VERSION" | cut -d'+' -f2)
|
||||
NEW_BUILD=$((CURRENT_BUILD + 1))
|
||||
|
||||
echo "current_build=$CURRENT_BUILD" >> $GITHUB_OUTPUT
|
||||
echo "new_build=$NEW_BUILD" >> $GITHUB_OUTPUT
|
||||
echo "Current build: $CURRENT_BUILD"
|
||||
echo "New build: $NEW_BUILD"
|
||||
|
||||
- name: Update pubspec.yaml
|
||||
run: |
|
||||
NEW_VERSION="${{ inputs.version }}+${{ steps.get_build.outputs.new_build }}"
|
||||
sed -i "s/^version: .*/version: $NEW_VERSION/" pubspec.yaml
|
||||
echo "Updated version to: $NEW_VERSION"
|
||||
cat pubspec.yaml | grep '^version:'
|
||||
|
||||
- name: Configure Git
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Commit version bump
|
||||
run: |
|
||||
git add pubspec.yaml
|
||||
git commit -m "chore: bump version to ${{ inputs.version }}"
|
||||
|
||||
- name: Create Git tag
|
||||
run: |
|
||||
git tag "${{ inputs.version }}"
|
||||
echo "Created tag: ${{ inputs.version }}"
|
||||
|
||||
- name: Push changes and tag
|
||||
run: |
|
||||
git push origin main
|
||||
git push origin --tags
|
||||
|
||||
- name: Trigger build workflow
|
||||
run: |
|
||||
curl -L \
|
||||
-X POST \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
https://api.github.com/repos/${{ github.repository }}/actions/workflows/build.yml/dispatches \
|
||||
-d '{"ref":"main"}'
|
||||
|
||||
echo "Build workflow triggered successfully"
|
||||
@@ -0,0 +1,69 @@
|
||||
# Contributing
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. Fork and clone the repository
|
||||
2. Run `flutter pub get` to install dependencies
|
||||
3. Run `dart run build_runner build` to generate code
|
||||
4. Start developing!
|
||||
|
||||
## Development
|
||||
|
||||
- Follow Dart/Flutter conventions
|
||||
- Run `dart format .` to format your code (note: generated files like `*.g.dart` are excluded from CI checks)
|
||||
- Run `flutter analyze` before submitting to check for issues
|
||||
- Run `flutter test` if tests are available
|
||||
- Test your changes thoroughly
|
||||
|
||||
### Code Quality Checks
|
||||
|
||||
The project includes automated CI checks that run on all pull requests:
|
||||
|
||||
1. **Code Formatting**: Ensures code follows Dart formatting standards
|
||||
- Run locally: `dart format .` to format all files
|
||||
- Note: CI only checks non-generated files (excludes `.g.dart`, `.freezed.dart`)
|
||||
- Generated files are reformatted automatically by build tools
|
||||
|
||||
2. **Static Analysis**: Checks for code issues and potential bugs
|
||||
- Run locally: `flutter analyze`
|
||||
- Note: CI excludes generated files from analysis (configured in `analysis_options.yaml`)
|
||||
|
||||
3. **Tests**: Runs unit and widget tests (when available)
|
||||
- Run locally: `flutter test`
|
||||
|
||||
All these checks must pass before your changes can be merged.
|
||||
|
||||
## Internationalization (i18n)
|
||||
|
||||
This project uses `slang` for internationalization with JSON files.
|
||||
|
||||
### Adding New Strings
|
||||
|
||||
1. Add your string to `lib/i18n/strings.i18n.json`:
|
||||
```json
|
||||
{
|
||||
"section": {
|
||||
"myNewString": "My new text"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. Run `dart run slang` to regenerate translation files
|
||||
|
||||
3. Use in your code:
|
||||
```dart
|
||||
Text(t.section.myNewString)
|
||||
```
|
||||
|
||||
### Adding New Languages
|
||||
|
||||
1. Create new JSON file: `lib/i18n/strings_[locale].i18n.json`
|
||||
2. Copy structure from `strings.i18n.json` and translate values
|
||||
3. Run `dart run slang` to regenerate files
|
||||
|
||||
### Guidelines
|
||||
|
||||
- Organize strings logically in nested objects
|
||||
- Use camelCase for keys
|
||||
- Keep strings concise and clear
|
||||
- Always run `dart run slang` after changes
|
||||
@@ -14,7 +14,10 @@ Plezy is a modern Plex media client that provides a seamless streaming experienc
|
||||
## Download
|
||||
|
||||
### Mobile
|
||||
Coming soon
|
||||
<a href='https://apps.apple.com/us/app/id6754315964'><img height='60' alt='Download on the App Store' src='./assets/app-store-badge.png'/></a>
|
||||
<a href='https://play.google.com/store/apps/details?id=com.edde746.plezy'><img height='60' alt='Get it on Google Play' src='./assets/play-store-badge.png'/></a>
|
||||
|
||||
> Google Play version is in closed testing ([required by Google](https://support.google.com/googleplay/android-developer/answer/14151465#overview)). Join the [Google Group](https://groups.google.com/g/plezy-testers-2) to get access.
|
||||
|
||||
### Desktop
|
||||
- [Windows (x64)](https://github.com/edde746/plezy/releases/latest/download/plezy-windows-installer.exe)
|
||||
@@ -74,6 +77,25 @@ flutter run
|
||||
|
||||
## Development
|
||||
|
||||
### Code Quality
|
||||
|
||||
Before submitting changes, ensure your code passes all quality checks:
|
||||
|
||||
```bash
|
||||
# Format code (including generated files)
|
||||
dart format .
|
||||
|
||||
# Analyze code for issues
|
||||
flutter analyze
|
||||
|
||||
# Run tests (if available)
|
||||
flutter test
|
||||
```
|
||||
|
||||
**Note**: CI checks exclude generated files (`.g.dart`, `.freezed.dart`) from formatting and analysis checks. You can run `dart format .` locally to format everything, but only your hand-written code will be validated in CI.
|
||||
|
||||
These checks are automatically run in CI for all pull requests.
|
||||
|
||||
### Code Generation
|
||||
|
||||
The project uses code generation for JSON serialization. After modifying model classes, run:
|
||||
|
||||
@@ -1 +1,12 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
analyzer:
|
||||
exclude:
|
||||
- "**/*.g.dart"
|
||||
- "**/*.freezed.dart"
|
||||
plugins:
|
||||
- dart_code_linter
|
||||
|
||||
dart_code_linter:
|
||||
rules:
|
||||
- avoid-unused-parameters
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
<!-- Internet access permissions -->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
|
||||
<!-- Media session permissions for OS media controls -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
|
||||
|
||||
<!-- Explicitly remove media permissions that media_kit may add -->
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" tools:node="remove" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" tools:node="remove" />
|
||||
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 820 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
@@ -1,5 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
<foreground>
|
||||
<inset
|
||||
android:drawable="@drawable/ic_launcher_foreground"
|
||||
android:inset="16%" />
|
||||
</foreground>
|
||||
<monochrome>
|
||||
<inset
|
||||
android:drawable="@mipmap/ic_launcher_monochrome"
|
||||
android:inset="16%" />
|
||||
</monochrome>
|
||||
</adaptive-icon>
|
||||
|
||||
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#1a1a1a</color>
|
||||
</resources>
|
||||
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 21 KiB |
@@ -1,9 +1,13 @@
|
||||
PODS:
|
||||
- connectivity_plus (0.0.1):
|
||||
- Flutter
|
||||
- Flutter (1.0.0)
|
||||
- media_kit_libs_ios_video (1.0.4):
|
||||
- Flutter
|
||||
- media_kit_video (0.0.1):
|
||||
- Flutter
|
||||
- os_media_controls (0.0.1):
|
||||
- Flutter
|
||||
- package_info_plus (0.4.5):
|
||||
- Flutter
|
||||
- path_provider_foundation (0.0.1):
|
||||
@@ -23,9 +27,11 @@ PODS:
|
||||
- Flutter
|
||||
|
||||
DEPENDENCIES:
|
||||
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
||||
- Flutter (from `Flutter`)
|
||||
- media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`)
|
||||
- media_kit_video (from `.symlinks/plugins/media_kit_video/ios`)
|
||||
- os_media_controls (from `.symlinks/plugins/os_media_controls/ios`)
|
||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||
@@ -35,12 +41,16 @@ DEPENDENCIES:
|
||||
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
connectivity_plus:
|
||||
:path: ".symlinks/plugins/connectivity_plus/ios"
|
||||
Flutter:
|
||||
:path: Flutter
|
||||
media_kit_libs_ios_video:
|
||||
:path: ".symlinks/plugins/media_kit_libs_ios_video/ios"
|
||||
media_kit_video:
|
||||
:path: ".symlinks/plugins/media_kit_video/ios"
|
||||
os_media_controls:
|
||||
:path: ".symlinks/plugins/os_media_controls/ios"
|
||||
package_info_plus:
|
||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||
path_provider_foundation:
|
||||
@@ -57,12 +67,14 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/wakelock_plus/ios"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
|
||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||
media_kit_libs_ios_video: 5a18affdb97d1f5d466dc79988b13eff6c5e2854
|
||||
media_kit_video: 1746e198cb697d1ffb734b1d05ec429d1fcd1474
|
||||
os_media_controls: 86dceab6245a5325af90fc0fdebe243c42d789b4
|
||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
|
||||
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
|
||||
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
|
||||
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
||||
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
||||
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
|
||||
volume_controller: 3657a1f65bedb98fa41ff7dc5793537919f31b12
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
import AVFoundation
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate {
|
||||
@@ -8,6 +9,18 @@ import UIKit
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
GeneratedPluginRegistrant.register(with: self)
|
||||
|
||||
// Configure audio session for media playback
|
||||
do {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try session.setCategory(.playback, mode: .default)
|
||||
try session.setActive(true)
|
||||
} catch {
|
||||
print("Failed to configure audio session: \(error)")
|
||||
}
|
||||
|
||||
application.beginReceivingRemoteControlEvents()
|
||||
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ class PlexConfig {
|
||||
final String platform;
|
||||
final String? device;
|
||||
final bool acceptJson;
|
||||
final String? machineIdentifier;
|
||||
|
||||
PlexConfig({
|
||||
required this.baseUrl,
|
||||
@@ -19,6 +20,7 @@ class PlexConfig {
|
||||
this.platform = 'Flutter',
|
||||
this.device,
|
||||
this.acceptJson = true,
|
||||
this.machineIdentifier,
|
||||
});
|
||||
|
||||
static Future<PlexConfig> create({
|
||||
@@ -29,6 +31,7 @@ class PlexConfig {
|
||||
String? platform,
|
||||
String? device,
|
||||
bool acceptJson = true,
|
||||
String? machineIdentifier,
|
||||
}) async {
|
||||
final packageInfo = await PackageInfo.fromPlatform();
|
||||
return PlexConfig(
|
||||
@@ -40,6 +43,7 @@ class PlexConfig {
|
||||
platform: platform ?? 'Flutter',
|
||||
device: device,
|
||||
acceptJson: acceptJson,
|
||||
machineIdentifier: machineIdentifier,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -51,6 +55,7 @@ class PlexConfig {
|
||||
'X-Plex-Platform': platform,
|
||||
if (device != null) 'X-Plex-Device': device!,
|
||||
if (acceptJson) 'Accept': 'application/json',
|
||||
'Accept-Charset': 'utf-8',
|
||||
};
|
||||
|
||||
if (token != null) {
|
||||
@@ -69,6 +74,7 @@ class PlexConfig {
|
||||
String? platform,
|
||||
String? device,
|
||||
bool? acceptJson,
|
||||
String? machineIdentifier,
|
||||
}) {
|
||||
return PlexConfig(
|
||||
baseUrl: baseUrl ?? this.baseUrl,
|
||||
@@ -79,6 +85,7 @@ class PlexConfig {
|
||||
platform: platform ?? this.platform,
|
||||
device: device ?? this.device,
|
||||
acceptJson: acceptJson ?? this.acceptJson,
|
||||
machineIdentifier: machineIdentifier ?? this.machineIdentifier,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/// Layout and sizing constants used throughout the application
|
||||
/// Screen width breakpoints for responsive design
|
||||
class ScreenBreakpoints {
|
||||
/// Breakpoint for tablet devices (600px)
|
||||
static const double tablet = 600;
|
||||
|
||||
/// Breakpoint for desktop devices (1200px)
|
||||
static const double desktop = 1200;
|
||||
|
||||
/// Breakpoint for large desktop devices (1600px)
|
||||
static const double largeDesktop = 1600;
|
||||
}
|
||||
|
||||
/// Grid layout constants
|
||||
class GridLayoutConstants {
|
||||
/// Maximum cross-axis extent for grid items in comfortable density mode
|
||||
static const double comfortableDesktop = 280;
|
||||
static const double comfortableTablet = 240;
|
||||
static const double comfortableMobile = 200;
|
||||
|
||||
/// Maximum cross-axis extent for grid items in compact density mode
|
||||
static const double compactDesktop = 200;
|
||||
static const double compactTablet = 170;
|
||||
static const double compactMobile = 140;
|
||||
|
||||
/// Maximum cross-axis extent for grid items in normal density mode
|
||||
static const double normalDesktop = 240;
|
||||
static const double normalTablet = 200;
|
||||
static const double normalMobile = 170;
|
||||
|
||||
/// Default aspect ratio for media cards (poster)
|
||||
static const double posterAspectRatio = 2 / 3.3;
|
||||
|
||||
/// Grid spacing
|
||||
static const double crossAxisSpacing = 0;
|
||||
static const double mainAxisSpacing = 0;
|
||||
}
|
||||
@@ -0,0 +1,508 @@
|
||||
{
|
||||
"app": {
|
||||
"title": "Plezy",
|
||||
"loading": "Loading..."
|
||||
},
|
||||
"auth": {
|
||||
"signInWithPlex": "Sign in with Plex",
|
||||
"showQRCode": "Show QR Code",
|
||||
"cancel": "Cancel",
|
||||
"authenticate": "Authenticate",
|
||||
"retry": "Retry",
|
||||
"debugEnterToken": "Debug: Enter Plex Token",
|
||||
"plexTokenLabel": "Plex Auth Token",
|
||||
"plexTokenHint": "Enter your Plex.tv token",
|
||||
"authenticationTimeout": "Authentication timed out. Please try again.",
|
||||
"scanQRCodeInstruction": "Scan this QR code with a device logged into Plex to authenticate.",
|
||||
"waitingForAuth": "Waiting for authentication...\nPlease complete sign-in in your browser."
|
||||
},
|
||||
"common": {
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"close": "Close",
|
||||
"clear": "Clear",
|
||||
"reset": "Reset",
|
||||
"later": "Later",
|
||||
"submit": "Submit",
|
||||
"confirm": "Confirm",
|
||||
"retry": "Retry",
|
||||
"playNow": "Play Now",
|
||||
"logout": "Logout",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"owned": "Owned",
|
||||
"shared": "Shared",
|
||||
"current": "CURRENT",
|
||||
"unknown": "Unknown",
|
||||
"refresh": "Refresh",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"server": "Server",
|
||||
"delete": "Delete",
|
||||
"shuffle": "Shuffle",
|
||||
"addTo": "Add to..."
|
||||
},
|
||||
"screens": {
|
||||
"licenses": "Licenses",
|
||||
"switchProfile": "Switch Profile",
|
||||
"subtitleStyling": "Subtitle Styling",
|
||||
"search": "Search",
|
||||
"logs": "Logs"
|
||||
},
|
||||
"update": {
|
||||
"available": "Update Available",
|
||||
"versionAvailable": "Version ${version} is available",
|
||||
"currentVersion": "Current: ${version}",
|
||||
"skipVersion": "Skip This Version",
|
||||
"viewRelease": "View Release",
|
||||
"latestVersion": "You are on the latest version",
|
||||
"checkFailed": "Failed to check for updates"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"language": "Language",
|
||||
"theme": "Theme",
|
||||
"appearance": "Appearance",
|
||||
"videoPlayback": "Video Playback",
|
||||
"shufflePlay": "Shuffle Play",
|
||||
"advanced": "Advanced",
|
||||
"useSeasonPostersDescription": "Show season poster instead of series poster for episodes",
|
||||
"showHeroSectionDescription": "Display featured content carousel on home screen",
|
||||
"secondsLabel": "Seconds",
|
||||
"minutesLabel": "Minutes",
|
||||
"secondsShort": "s",
|
||||
"minutesShort": "m",
|
||||
"durationHint": "Enter duration (${min}-${max})",
|
||||
"systemTheme": "System",
|
||||
"systemThemeDescription": "Follow system settings",
|
||||
"lightTheme": "Light",
|
||||
"darkTheme": "Dark",
|
||||
"libraryDensity": "Library Density",
|
||||
"compact": "Compact",
|
||||
"compactDescription": "Smaller cards, more items visible",
|
||||
"normal": "Normal",
|
||||
"normalDescription": "Default size",
|
||||
"comfortable": "Comfortable",
|
||||
"comfortableDescription": "Larger cards, fewer items visible",
|
||||
"viewMode": "View Mode",
|
||||
"gridView": "Grid",
|
||||
"gridViewDescription": "Display items in a grid layout",
|
||||
"listView": "List",
|
||||
"listViewDescription": "Display items in a list layout",
|
||||
"useSeasonPosters": "Use Season Posters",
|
||||
"showHeroSection": "Show Hero Section",
|
||||
"hardwareDecoding": "Hardware Decoding",
|
||||
"hardwareDecodingDescription": "Use hardware acceleration when available",
|
||||
"bufferSize": "Buffer Size",
|
||||
"bufferSizeMB": "${size}MB",
|
||||
"subtitleStyling": "Subtitle Styling",
|
||||
"subtitleStylingDescription": "Customize subtitle appearance",
|
||||
"smallSkipDuration": "Small Skip Duration",
|
||||
"largeSkipDuration": "Large Skip Duration",
|
||||
"secondsUnit": "${seconds} seconds",
|
||||
"defaultSleepTimer": "Default Sleep Timer",
|
||||
"minutesUnit": "${minutes} minutes",
|
||||
"rememberTrackSelections": "Remember track selections per show/movie",
|
||||
"rememberTrackSelectionsDescription": "Automatically save audio and subtitle language preferences when you change tracks during playback",
|
||||
"unwatchedOnly": "Unwatched Only",
|
||||
"unwatchedOnlyDescription": "Only include unwatched episodes in shuffle queue",
|
||||
"shuffleOrderNavigation": "Shuffle Order Navigation",
|
||||
"shuffleOrderNavigationDescription": "Next/previous buttons follow shuffled order",
|
||||
"loopShuffleQueue": "Loop Shuffle Queue",
|
||||
"loopShuffleQueueDescription": "Restart queue when reaching the end",
|
||||
"videoPlayerControls": "Video Player Controls",
|
||||
"keyboardShortcuts": "Keyboard Shortcuts",
|
||||
"keyboardShortcutsDescription": "Customize keyboard shortcuts",
|
||||
"debugLogging": "Debug Logging",
|
||||
"debugLoggingDescription": "Enable detailed logging for troubleshooting",
|
||||
"viewLogs": "View Logs",
|
||||
"viewLogsDescription": "View application logs",
|
||||
"clearCache": "Clear Cache",
|
||||
"clearCacheDescription": "This will clear all cached images and data. The app may take longer to load content after clearing the cache.",
|
||||
"clearCacheSuccess": "Cache cleared successfully",
|
||||
"resetSettings": "Reset Settings",
|
||||
"resetSettingsDescription": "This will reset all settings to their default values. This action cannot be undone.",
|
||||
"resetSettingsSuccess": "Settings reset successfully",
|
||||
"shortcutsReset": "Shortcuts reset to defaults",
|
||||
"about": "About",
|
||||
"aboutDescription": "App information and licenses",
|
||||
"updates": "Updates",
|
||||
"updateAvailable": "Update Available",
|
||||
"checkForUpdates": "Check for Updates",
|
||||
"validationErrorEnterNumber": "Please enter a valid number",
|
||||
"validationErrorDuration": "Duration must be between ${min} and ${max} ${unit}",
|
||||
"shortcutAlreadyAssigned": "Shortcut already assigned to ${action}",
|
||||
"shortcutUpdated": "Shortcut updated for ${action}"
|
||||
},
|
||||
"search": {
|
||||
"hint": "Search movies, shows, music...",
|
||||
"tryDifferentTerm": "Try a different search term",
|
||||
"searchYourMedia": "Search your media",
|
||||
"enterTitleActorOrKeyword": "Enter a title, actor, or keyword"
|
||||
},
|
||||
"hotkeys": {
|
||||
"setShortcutFor": "Set Shortcut for ${actionName}",
|
||||
"clearShortcut": "Clear shortcut"
|
||||
},
|
||||
"pinEntry": {
|
||||
"enterPin": "Enter PIN",
|
||||
"showPin": "Show PIN",
|
||||
"hidePin": "Hide PIN"
|
||||
},
|
||||
"fileInfo": {
|
||||
"title": "File Info",
|
||||
"video": "Video",
|
||||
"audio": "Audio",
|
||||
"file": "File",
|
||||
"advanced": "Advanced",
|
||||
"codec": "Codec",
|
||||
"resolution": "Resolution",
|
||||
"bitrate": "Bitrate",
|
||||
"frameRate": "Frame Rate",
|
||||
"aspectRatio": "Aspect Ratio",
|
||||
"profile": "Profile",
|
||||
"bitDepth": "Bit Depth",
|
||||
"colorSpace": "Color Space",
|
||||
"colorRange": "Color Range",
|
||||
"colorPrimaries": "Color Primaries",
|
||||
"chromaSubsampling": "Chroma Subsampling",
|
||||
"channels": "Channels",
|
||||
"path": "Path",
|
||||
"size": "Size",
|
||||
"container": "Container",
|
||||
"duration": "Duration",
|
||||
"optimizedForStreaming": "Optimized for Streaming",
|
||||
"has64bitOffsets": "64-bit Offsets"
|
||||
},
|
||||
"mediaMenu": {
|
||||
"markAsWatched": "Mark as Watched",
|
||||
"markAsUnwatched": "Mark as Unwatched",
|
||||
"removeFromContinueWatching": "Remove from Continue Watching",
|
||||
"goToSeries": "Go to series",
|
||||
"goToSeason": "Go to season",
|
||||
"shufflePlay": "Shuffle Play",
|
||||
"fileInfo": "File Info"
|
||||
},
|
||||
"accessibility": {
|
||||
"mediaCardMovie": "${title}, movie",
|
||||
"mediaCardShow": "${title}, TV show",
|
||||
"mediaCardEpisode": "${title}, ${episodeInfo}",
|
||||
"mediaCardSeason": "${title}, ${seasonInfo}",
|
||||
"mediaCardWatched": "watched",
|
||||
"mediaCardPartiallyWatched": "${percent} percent watched",
|
||||
"mediaCardUnwatched": "unwatched",
|
||||
"tapToPlay": "Tap to play"
|
||||
},
|
||||
"tooltips": {
|
||||
"shufflePlay": "Shuffle play",
|
||||
"markAsWatched": "Mark as watched",
|
||||
"markAsUnwatched": "Mark as unwatched"
|
||||
},
|
||||
"videoControls": {
|
||||
"audioLabel": "Audio",
|
||||
"subtitlesLabel": "Subtitles",
|
||||
"resetToZero": "Reset to 0ms",
|
||||
"addTime": "+${amount}${unit}",
|
||||
"minusTime": "-${amount}${unit}",
|
||||
"playsLater": "${label} plays later",
|
||||
"playsEarlier": "${label} plays earlier",
|
||||
"noOffset": "No offset",
|
||||
"letterbox": "Letterbox",
|
||||
"fillScreen": "Fill screen",
|
||||
"stretch": "Stretch",
|
||||
"lockRotation": "Lock rotation",
|
||||
"unlockRotation": "Unlock rotation",
|
||||
"sleepTimer": "Sleep Timer",
|
||||
"timerActive": "Timer Active",
|
||||
"playbackWillPauseIn": "Playback will pause in ${duration}",
|
||||
"sleepTimerCompleted": "Sleep timer completed - playback paused",
|
||||
"playButton": "Play",
|
||||
"pauseButton": "Pause",
|
||||
"seekBackwardButton": "Seek backward ${seconds} seconds",
|
||||
"seekForwardButton": "Seek forward ${seconds} seconds",
|
||||
"previousButton": "Previous episode",
|
||||
"nextButton": "Next episode",
|
||||
"previousChapterButton": "Previous chapter",
|
||||
"nextChapterButton": "Next chapter",
|
||||
"volumeButton": "Volume",
|
||||
"muteButton": "Mute",
|
||||
"unmuteButton": "Unmute",
|
||||
"settingsButton": "Video settings",
|
||||
"audioTrackButton": "Audio tracks",
|
||||
"subtitlesButton": "Subtitles",
|
||||
"chaptersButton": "Chapters",
|
||||
"versionsButton": "Video versions",
|
||||
"aspectRatioButton": "Aspect ratio",
|
||||
"fullscreenButton": "Enter fullscreen",
|
||||
"exitFullscreenButton": "Exit fullscreen",
|
||||
"rotationLockButton": "Rotation lock",
|
||||
"timelineSlider": "Video timeline",
|
||||
"volumeSlider": "Volume level",
|
||||
"backButton": "Back"
|
||||
},
|
||||
"userStatus": {
|
||||
"admin": "Admin",
|
||||
"restricted": "Restricted",
|
||||
"protected": "Protected",
|
||||
"current": "CURRENT"
|
||||
},
|
||||
"messages": {
|
||||
"markedAsWatched": "Marked as watched",
|
||||
"markedAsUnwatched": "Marked as unwatched",
|
||||
"removedFromContinueWatching": "Removed from Continue Watching",
|
||||
"errorLoading": "Error: ${error}",
|
||||
"fileInfoNotAvailable": "File information not available",
|
||||
"errorLoadingFileInfo": "Error loading file info: ${error}",
|
||||
"errorLoadingSeries": "Error loading series",
|
||||
"errorLoadingSeason": "Error loading season",
|
||||
"musicNotSupported": "Music playback is not yet supported",
|
||||
"logsCleared": "Logs cleared",
|
||||
"logsCopied": "Logs copied to clipboard",
|
||||
"noLogsAvailable": "No logs available",
|
||||
"libraryScanning": "Scanning \"${title}\"...",
|
||||
"libraryScanStarted": "Library scan started for \"${title}\"",
|
||||
"libraryScanFailed": "Failed to scan library: ${error}",
|
||||
"metadataRefreshing": "Refreshing metadata for \"${title}\"...",
|
||||
"metadataRefreshStarted": "Metadata refresh started for \"${title}\"",
|
||||
"metadataRefreshFailed": "Failed to refresh metadata: ${error}",
|
||||
"noPlexToken": "No Plex token found. Please login again.",
|
||||
"logoutConfirm": "Are you sure you want to logout?",
|
||||
"noSeasonsFound": "No seasons found",
|
||||
"noEpisodesFound": "No episodes found in first season",
|
||||
"noEpisodesFoundGeneral": "No episodes found",
|
||||
"noResultsFound": "No results found",
|
||||
"sleepTimerSet": "Sleep timer set for ${label}",
|
||||
"failedToSwitchProfile": "Failed to switch to ${displayName}",
|
||||
"noItemsAvailable": "No items available",
|
||||
"failedToCreatePlayQueue": "Failed to create play queue",
|
||||
"failedToCreatePlayQueueNoItems": "Failed to create play queue - no items",
|
||||
"failedPlayback": "Failed to ${action}: ${error}"
|
||||
},
|
||||
"profile": {
|
||||
"noUsersAvailable": "No users available"
|
||||
},
|
||||
"subtitlingStyling": {
|
||||
"stylingOptions": "Styling Options",
|
||||
"fontSize": "Font Size",
|
||||
"textColor": "Text Color",
|
||||
"borderSize": "Border Size",
|
||||
"borderColor": "Border Color",
|
||||
"backgroundOpacity": "Background Opacity",
|
||||
"backgroundColor": "Background Color"
|
||||
},
|
||||
"dialog": {
|
||||
"confirmAction": "Confirm Action",
|
||||
"areYouSure": "Are you sure you want to perform this action?",
|
||||
"cancel": "Cancel",
|
||||
"playNow": "Play Now"
|
||||
},
|
||||
"discover": {
|
||||
"title": "Discover",
|
||||
"switchProfile": "Switch Profile",
|
||||
"logout": "Logout",
|
||||
"noContentAvailable": "No content available",
|
||||
"addMediaToLibraries": "Add some media to your libraries",
|
||||
"continueWatching": "Continue Watching",
|
||||
"recentlyAdded": "Recently Added",
|
||||
"play": "Play",
|
||||
"resume": "Resume",
|
||||
"playEpisode": "Play S${season}, E${episode}",
|
||||
"resumeEpisode": "Resume S${season}, E${episode}",
|
||||
"pause": "Pause",
|
||||
"overview": "Overview",
|
||||
"cast": "Cast",
|
||||
"seasons": "Seasons",
|
||||
"studio": "Studio",
|
||||
"rating": "Rating",
|
||||
"watched": "Watched",
|
||||
"episodeCount": "${count} episodes",
|
||||
"watchedProgress": "${watched}/${total} watched",
|
||||
"movie": "Movie",
|
||||
"tvShow": "TV Show",
|
||||
"minutesLeft": "${minutes} min left"
|
||||
},
|
||||
"errors": {
|
||||
"searchFailed": "Search failed: ${error}",
|
||||
"connectionTimeout": "Connection timeout while loading ${context}",
|
||||
"connectionFailed": "Unable to connect to Plex server",
|
||||
"failedToLoad": "Failed to load ${context}: ${error}",
|
||||
"noClientAvailable": "No client available",
|
||||
"authenticationFailed": "Authentication failed: ${error}",
|
||||
"couldNotLaunchUrl": "Could not launch auth URL",
|
||||
"pleaseEnterToken": "Please enter a token",
|
||||
"invalidToken": "Invalid token",
|
||||
"failedToVerifyToken": "Failed to verify token: ${error}",
|
||||
"failedToSwitchProfile": "Failed to switch to ${displayName}",
|
||||
"connectionFailedGeneric": "Connection failed"
|
||||
},
|
||||
"libraries": {
|
||||
"title": "Libraries",
|
||||
"scanLibraryFiles": "Scan Library Files",
|
||||
"scanLibrary": "Scan Library",
|
||||
"analyze": "Analyze",
|
||||
"analyzeLibrary": "Analyze Library",
|
||||
"refreshMetadata": "Refresh Metadata",
|
||||
"emptyTrash": "Empty Trash",
|
||||
"emptyingTrash": "Emptying trash for \"${title}\"...",
|
||||
"trashEmptied": "Trash emptied for \"${title}\"",
|
||||
"failedToEmptyTrash": "Failed to empty trash: ${error}",
|
||||
"analyzing": "Analyzing \"${title}\"...",
|
||||
"analysisStarted": "Analysis started for \"${title}\"",
|
||||
"failedToAnalyze": "Failed to analyze library: ${error}",
|
||||
"noLibrariesFound": "No libraries found",
|
||||
"thisLibraryIsEmpty": "This library is empty",
|
||||
"all": "All",
|
||||
"clearAll": "Clear All",
|
||||
"scanLibraryConfirm": "Are you sure you want to scan \"${title}\"?",
|
||||
"analyzeLibraryConfirm": "Are you sure you want to analyze \"${title}\"?",
|
||||
"refreshMetadataConfirm": "Are you sure you want to refresh metadata for \"${title}\"?",
|
||||
"emptyTrashConfirm": "Are you sure you want to empty trash for \"${title}\"?",
|
||||
"manageLibraries": "Manage Libraries",
|
||||
"sort": "Sort",
|
||||
"sortBy": "Sort By",
|
||||
"filters": "Filters",
|
||||
"loadingLibraryWithCount": "Loading library... (${count} items loaded)",
|
||||
"confirmActionMessage": "Are you sure you want to perform this action?",
|
||||
"showLibrary": "Show library",
|
||||
"hideLibrary": "Hide library",
|
||||
"libraryOptions": "Library options",
|
||||
"content": "library content",
|
||||
"selectLibrary": "Select library",
|
||||
"filtersWithCount": "Filters (${count})",
|
||||
"noRecommendations": "No recommendations available",
|
||||
"noCollections": "No collections in this library",
|
||||
"noFoldersFound": "No folders found",
|
||||
"folders": "folders",
|
||||
"tabs": {
|
||||
"recommended": "Recommended",
|
||||
"browse": "Browse",
|
||||
"collections": "Collections",
|
||||
"playlists": "Playlists"
|
||||
},
|
||||
"groupings": {
|
||||
"all": "All",
|
||||
"movies": "Movies",
|
||||
"shows": "TV Shows",
|
||||
"seasons": "Seasons",
|
||||
"episodes": "Episodes",
|
||||
"folders": "Folders"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"title": "About",
|
||||
"openSourceLicenses": "Open Source Licenses",
|
||||
"versionLabel": "Version ${version}",
|
||||
"appDescription": "A beautiful Plex client for Flutter",
|
||||
"viewLicensesDescription": "View licenses of third-party libraries"
|
||||
},
|
||||
"serverSelection": {
|
||||
"connectingToServer": "Connecting to server...",
|
||||
"connectingToAllServers": "Connecting to all servers...",
|
||||
"connectToAllServers": "Connect to All Servers",
|
||||
"allServerConnectionsFailed": "Failed to connect to any servers. Please check your network and try again.",
|
||||
"foundServers": "Found ${count} server(s)",
|
||||
"orSelectIndividually": "Or select a server individually:",
|
||||
"serverDebugCopied": "Server debug data copied to clipboard",
|
||||
"copyDebugData": "Copy Debug Data",
|
||||
"noServersFound": "No servers found",
|
||||
"malformedServerData": "Found ${count} server(s) with malformed data. No valid servers available.",
|
||||
"incompleteServerInfo": "Some servers have incomplete information and were skipped. Please check your Plex.tv account.",
|
||||
"incompleteConnectionInfo": "Server connection information is incomplete. Please try again.",
|
||||
"malformedServerInfo": "Server information is malformed: ${message}",
|
||||
"networkConnectionFailed": "Network connection failed. Please check your internet connection and try again.",
|
||||
"authenticationFailed": "Authentication failed. Please sign in again.",
|
||||
"plexServiceUnavailable": "Plex service unavailable. Please try again later.",
|
||||
"failedToLoadServers": "Failed to load servers: ${error}"
|
||||
},
|
||||
"hubDetail": {
|
||||
"title": "Title",
|
||||
"releaseYear": "Release Year",
|
||||
"dateAdded": "Date Added",
|
||||
"rating": "Rating",
|
||||
"noItemsFound": "No items found"
|
||||
},
|
||||
"logs": {
|
||||
"title": "Logs",
|
||||
"clearLogs": "Clear Logs",
|
||||
"copyLogs": "Copy Logs",
|
||||
"exportLogs": "Export Logs",
|
||||
"noLogsToShow": "No logs to show",
|
||||
"error": "Error:",
|
||||
"stackTrace": "Stack Trace:"
|
||||
},
|
||||
"licenses": {
|
||||
"relatedPackages": "Related Packages",
|
||||
"license": "License",
|
||||
"licenseNumber": "License ${number}",
|
||||
"licensesCount": "${count} licenses"
|
||||
},
|
||||
"navigation": {
|
||||
"home": "Home",
|
||||
"search": "Search",
|
||||
"libraries": "Libraries",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"collections": {
|
||||
"title": "Collections",
|
||||
"collection": "Collection",
|
||||
"empty": "Collection is empty",
|
||||
"noItems": "No items in this collection",
|
||||
"unknownLibrarySection": "Cannot delete: Unknown library section",
|
||||
"deleteCollection": "Delete Collection",
|
||||
"deleteConfirm": "Are you sure you want to delete \"${title}\"? This action cannot be undone.",
|
||||
"deleted": "Collection deleted",
|
||||
"deleteFailed": "Failed to delete collection",
|
||||
"deleteFailedWithError": "Failed to delete collection: ${error}",
|
||||
"failedToLoadItems": "Failed to load collection items: ${error}",
|
||||
"addTo": "Add to collection",
|
||||
"selectCollection": "Select Collection",
|
||||
"createNewCollection": "Create New Collection",
|
||||
"collectionName": "Collection Name",
|
||||
"enterCollectionName": "Enter collection name",
|
||||
"addedToCollection": "Added to collection",
|
||||
"errorAddingToCollection": "Failed to add to collection",
|
||||
"created": "Collection created",
|
||||
"removeFromCollection": "Remove from collection",
|
||||
"removeFromCollectionConfirm": "Remove \"${title}\" from this collection?",
|
||||
"removedFromCollection": "Removed from collection",
|
||||
"removeFromCollectionFailed": "Failed to remove from collection",
|
||||
"removeFromCollectionError": "Error removing from collection: ${error}"
|
||||
},
|
||||
"playlists": {
|
||||
"title": "Playlists",
|
||||
"playlist": "Playlist",
|
||||
"noPlaylists": "No playlists found",
|
||||
"create": "Create Playlist",
|
||||
"newPlaylist": "New Playlist",
|
||||
"playlistName": "Playlist Name",
|
||||
"enterPlaylistName": "Enter playlist name",
|
||||
"edit": "Edit Playlist",
|
||||
"delete": "Delete Playlist",
|
||||
"addTo": "Add to Playlist",
|
||||
"addItems": "Add Items",
|
||||
"removeItem": "Remove from Playlist",
|
||||
"clearPlaylist": "Clear Playlist",
|
||||
"playAll": "Play All",
|
||||
"shuffle": "Shuffle",
|
||||
"smartPlaylist": "Smart Playlist",
|
||||
"regularPlaylist": "Regular Playlist",
|
||||
"itemCount": "${count} items",
|
||||
"oneItem": "1 item",
|
||||
"emptyPlaylist": "This playlist is empty",
|
||||
"deleteConfirm": "Delete Playlist?",
|
||||
"deleteMessage": "Are you sure you want to delete \"${name}\"?",
|
||||
"created": "Playlist created",
|
||||
"updated": "Playlist updated",
|
||||
"deleted": "Playlist deleted",
|
||||
"itemAdded": "Added to playlist",
|
||||
"itemRemoved": "Removed from playlist",
|
||||
"selectPlaylist": "Select Playlist",
|
||||
"createNewPlaylist": "Create New Playlist",
|
||||
"errorCreating": "Failed to create playlist",
|
||||
"errorDeleting": "Failed to delete playlist",
|
||||
"errorLoading": "Failed to load playlists",
|
||||
"errorAdding": "Failed to add to playlist",
|
||||
"errorReordering": "Failed to reorder playlist item",
|
||||
"errorRemoving": "Failed to remove from playlist"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
{
|
||||
"app": {
|
||||
"title": "Plezy",
|
||||
"loading": "Lädt..."
|
||||
},
|
||||
"auth": {
|
||||
"signInWithPlex": "Mit Plex anmelden",
|
||||
"showQRCode": "QR-Code anzeigen",
|
||||
"cancel": "Abbrechen",
|
||||
"authenticate": "Authentifizieren",
|
||||
"retry": "Erneut versuchen",
|
||||
"debugEnterToken": "Debug: Plex-Token eingeben",
|
||||
"plexTokenLabel": "Plex-Auth-Token",
|
||||
"plexTokenHint": "Plex.tv-Token eingeben",
|
||||
"authenticationTimeout": "Authentifizierung abgelaufen. Bitte erneut versuchen.",
|
||||
"scanQRCodeInstruction": "Diesen QR-Code mit einem bei Plex angemeldeten Gerät scannen, um zu authentifizieren.",
|
||||
"waitingForAuth": "Warte auf Authentifizierung...\nBitte Anmeldung im Browser abschließen."
|
||||
},
|
||||
"common": {
|
||||
"cancel": "Abbrechen",
|
||||
"save": "Speichern",
|
||||
"close": "Schließen",
|
||||
"clear": "Leeren",
|
||||
"reset": "Zurücksetzen",
|
||||
"later": "Später",
|
||||
"submit": "Senden",
|
||||
"confirm": "Bestätigen",
|
||||
"retry": "Erneut versuchen",
|
||||
"playNow": "Jetzt abspielen",
|
||||
"logout": "Abmelden",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"owned": "Besitzer",
|
||||
"shared": "Geteilt",
|
||||
"current": "AKTUELL",
|
||||
"unknown": "Unbekannt",
|
||||
"refresh": "Aktualisieren",
|
||||
"yes": "Ja",
|
||||
"no": "Nein",
|
||||
"server": "Server",
|
||||
"delete": "Löschen",
|
||||
"shuffle": "Zufall",
|
||||
"addTo": "Hinzufügen zu..."
|
||||
},
|
||||
"screens": {
|
||||
"licenses": "Lizenzen",
|
||||
"selectServer": "Server auswählen",
|
||||
"switchProfile": "Profil wechseln",
|
||||
"subtitleStyling": "Untertitel-Stil",
|
||||
"search": "Suche",
|
||||
"logs": "Protokolle"
|
||||
},
|
||||
"update": {
|
||||
"available": "Update verfügbar",
|
||||
"versionAvailable": "Version ${version} ist verfügbar",
|
||||
"currentVersion": "Aktuell: ${version}",
|
||||
"skipVersion": "Diese Version überspringen",
|
||||
"viewRelease": "Release anzeigen",
|
||||
"latestVersion": "Aktuellste Version installiert",
|
||||
"checkFailed": "Fehler bei der Updateprüfung"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Einstellungen",
|
||||
"language": "Sprache",
|
||||
"theme": "Design",
|
||||
"appearance": "Darstellung",
|
||||
"videoPlayback": "Videowiedergabe",
|
||||
"shufflePlay": "Zufallswiedergabe",
|
||||
"advanced": "Erweitert",
|
||||
"useSeasonPostersDescription": "Staffelposter statt Serienposter für Episoden anzeigen",
|
||||
"showHeroSectionDescription": "Bereich mit empfohlenen Inhalten auf der Startseite anzeigen",
|
||||
"secondsLabel": "Sekunden",
|
||||
"minutesLabel": "Minuten",
|
||||
"secondsShort": "s",
|
||||
"minutesShort": "m",
|
||||
"durationHint": "Dauer eingeben (${min}-${max})",
|
||||
"systemTheme": "System",
|
||||
"systemThemeDescription": "Systemeinstellungen folgen",
|
||||
"lightTheme": "Hell",
|
||||
"darkTheme": "Dunkel",
|
||||
"libraryDensity": "Mediathekdichte",
|
||||
"compact": "Kompakt",
|
||||
"compactDescription": "Kleinere Karten, mehr Elemente sichtbar",
|
||||
"normal": "Normal",
|
||||
"normalDescription": "Standardgröße",
|
||||
"comfortable": "Großzügig",
|
||||
"comfortableDescription": "Größere Karten, weniger Elemente sichtbar",
|
||||
"viewMode": "Ansichtsmodus",
|
||||
"gridView": "Raster",
|
||||
"gridViewDescription": "Elemente im Raster anzeigen",
|
||||
"listView": "Liste",
|
||||
"listViewDescription": "Elemente in Listenansicht anzeigen",
|
||||
"useSeasonPosters": "Staffelposter verwenden",
|
||||
"showHeroSection": "Hero-Bereich anzeigen",
|
||||
"hardwareDecoding": "Hardware-Decodierung",
|
||||
"hardwareDecodingDescription": "Hardwarebeschleunigung verwenden, sofern verfügbar",
|
||||
"bufferSize": "Puffergröße",
|
||||
"bufferSizeMB": "${size}MB",
|
||||
"subtitleStyling": "Untertitel-Stil",
|
||||
"subtitleStylingDescription": "Aussehen von Untertiteln anpassen",
|
||||
"smallSkipDuration": "Kleine Sprungdauer",
|
||||
"largeSkipDuration": "Große Sprungdauer",
|
||||
"secondsUnit": "${seconds} Sekunden",
|
||||
"defaultSleepTimer": "Standard-Sleep-Timer",
|
||||
"minutesUnit": "${minutes} Minuten",
|
||||
"rememberTrackSelections": "Spurauswahl pro Serie/Film merken",
|
||||
"rememberTrackSelectionsDescription": "Audio- und Untertitelsprache automatisch speichern, wenn während der Wiedergabe geändert",
|
||||
"unwatchedOnly": "Nur ungesehene",
|
||||
"unwatchedOnlyDescription": "Nur ungesehene Episoden in die Shuffle-Warteschlange aufnehmen",
|
||||
"shuffleOrderNavigation": "Navigation der Shuffle-Reihenfolge",
|
||||
"shuffleOrderNavigationDescription": "Weiter/Zurück folgt der zufälligen Reihenfolge",
|
||||
"loopShuffleQueue": "Shuffle-Warteschlange wiederholen",
|
||||
"loopShuffleQueueDescription": "Warteschlange neu starten, wenn das Ende erreicht ist",
|
||||
"videoPlayerControls": "Videoplayer-Steuerung",
|
||||
"keyboardShortcuts": "Tastenkürzel",
|
||||
"keyboardShortcutsDescription": "Tastenkürzel anpassen",
|
||||
"debugLogging": "Debug-Protokollierung",
|
||||
"debugLoggingDescription": "Detaillierte Protokolle zur Fehleranalyse aktivieren",
|
||||
"viewLogs": "Protokolle anzeigen",
|
||||
"viewLogsDescription": "App-Protokolle anzeigen",
|
||||
"clearCache": "Cache löschen",
|
||||
"clearCacheDescription": "Löscht alle zwischengespeicherten Bilder und Daten. Die App kann danach langsamer laden.",
|
||||
"clearCacheSuccess": "Cache erfolgreich gelöscht",
|
||||
"resetSettings": "Einstellungen zurücksetzen",
|
||||
"resetSettingsDescription": "Alle Einstellungen auf Standard zurücksetzen. Dies kann nicht rückgängig gemacht werden.",
|
||||
"resetSettingsSuccess": "Einstellungen erfolgreich zurückgesetzt",
|
||||
"shortcutsReset": "Tastenkürzel auf Standard zurückgesetzt",
|
||||
"about": "Über",
|
||||
"aboutDescription": "App-Informationen und Lizenzen",
|
||||
"updates": "Updates",
|
||||
"updateAvailable": "Update verfügbar",
|
||||
"checkForUpdates": "Nach Updates suchen",
|
||||
"validationErrorEnterNumber": "Bitte eine gültige Zahl eingeben",
|
||||
"validationErrorDuration": "Dauer muss zwischen ${min} und ${max} ${unit} liegen",
|
||||
"shortcutAlreadyAssigned": "Tastenkürzel bereits zugewiesen an ${action}",
|
||||
"shortcutUpdated": "Tastenkürzel aktualisiert für ${action}"
|
||||
},
|
||||
"search": {
|
||||
"hint": "Filme, Serien, Musik suchen...",
|
||||
"tryDifferentTerm": "Anderen Suchbegriff versuchen",
|
||||
"searchYourMedia": "In den eigenen Medien suchen",
|
||||
"enterTitleActorOrKeyword": "Titel, Schauspieler oder Stichwort eingeben"
|
||||
},
|
||||
"hotkeys": {
|
||||
"setShortcutFor": "Tastenkürzel festlegen für ${actionName}",
|
||||
"clearShortcut": "Kürzel löschen"
|
||||
},
|
||||
"pinEntry": {
|
||||
"enterPin": "PIN eingeben",
|
||||
"showPin": "PIN anzeigen",
|
||||
"hidePin": "PIN verbergen"
|
||||
},
|
||||
"fileInfo": {
|
||||
"title": "Dateiinfo",
|
||||
"video": "Video",
|
||||
"audio": "Audio",
|
||||
"file": "Datei",
|
||||
"advanced": "Erweitert",
|
||||
"codec": "Codec",
|
||||
"resolution": "Auflösung",
|
||||
"bitrate": "Bitrate",
|
||||
"frameRate": "Bildrate",
|
||||
"aspectRatio": "Seitenverhältnis",
|
||||
"profile": "Profil",
|
||||
"bitDepth": "Farbtiefe",
|
||||
"colorSpace": "Farbraum",
|
||||
"colorRange": "Farbbereich",
|
||||
"colorPrimaries": "Primärfarben",
|
||||
"chromaSubsampling": "Chroma-Subsampling",
|
||||
"channels": "Kanäle",
|
||||
"path": "Pfad",
|
||||
"size": "Größe",
|
||||
"container": "Container",
|
||||
"duration": "Dauer",
|
||||
"optimizedForStreaming": "Für Streaming optimiert",
|
||||
"has64bitOffsets": "64-Bit-Offsets"
|
||||
},
|
||||
"mediaMenu": {
|
||||
"markAsWatched": "Als gesehen markieren",
|
||||
"markAsUnwatched": "Als ungesehen markieren",
|
||||
"removeFromContinueWatching": "Aus ‚Weiterschauen‘ entfernen",
|
||||
"goToSeries": "Zur Serie",
|
||||
"goToSeason": "Zur Staffel",
|
||||
"shufflePlay": "Zufallswiedergabe",
|
||||
"fileInfo": "Dateiinfo"
|
||||
},
|
||||
"accessibility": {
|
||||
"mediaCardMovie": "${title}, Film",
|
||||
"mediaCardShow": "${title}, Serie",
|
||||
"mediaCardEpisode": "${title}, ${episodeInfo}",
|
||||
"mediaCardSeason": "${title}, ${seasonInfo}",
|
||||
"mediaCardWatched": "angesehen",
|
||||
"mediaCardPartiallyWatched": "${percent} Prozent angesehen",
|
||||
"mediaCardUnwatched": "ungeschaut",
|
||||
"tapToPlay": "Zum Abspielen tippen"
|
||||
},
|
||||
"tooltips": {
|
||||
"shufflePlay": "Zufallswiedergabe",
|
||||
"markAsWatched": "Als gesehen markieren",
|
||||
"markAsUnwatched": "Als ungesehen markieren"
|
||||
},
|
||||
"videoControls": {
|
||||
"audioLabel": "Audio",
|
||||
"subtitlesLabel": "Untertitel",
|
||||
"resetToZero": "Auf 0 ms zurücksetzen",
|
||||
"addTime": "+${amount}${unit}",
|
||||
"minusTime": "-${amount}${unit}",
|
||||
"playsLater": "${label} spielt später",
|
||||
"playsEarlier": "${label} spielt früher",
|
||||
"noOffset": "Kein Offset",
|
||||
"letterbox": "Letterbox",
|
||||
"fillScreen": "Bild füllen",
|
||||
"stretch": "Strecken",
|
||||
"lockRotation": "Rotation sperren",
|
||||
"unlockRotation": "Rotation entsperren",
|
||||
"sleepTimer": "Schlaftimer",
|
||||
"timerActive": "Schlaftimer aktiv",
|
||||
"playbackWillPauseIn": "Wiedergabe wird in ${duration} pausiert",
|
||||
"sleepTimerCompleted": "Schlaftimer abgelaufen – Wiedergabe pausiert",
|
||||
"playButton": "Wiedergeben",
|
||||
"pauseButton": "Pause",
|
||||
"seekBackwardButton": "${seconds} Sekunden zurück",
|
||||
"seekForwardButton": "${seconds} Sekunden vor",
|
||||
"previousButton": "Vorherige Episode",
|
||||
"nextButton": "Nächste Episode",
|
||||
"previousChapterButton": "Vorheriges Kapitel",
|
||||
"nextChapterButton": "Nächstes Kapitel",
|
||||
"volumeButton": "Lautstärke",
|
||||
"muteButton": "Stumm schalten",
|
||||
"unmuteButton": "Stummschaltung aufheben",
|
||||
"settingsButton": "Videoeinstellungen",
|
||||
"audioTrackButton": "Tonspuren",
|
||||
"subtitlesButton": "Untertitel",
|
||||
"chaptersButton": "Kapitel",
|
||||
"versionsButton": "Videoversionen",
|
||||
"aspectRatioButton": "Seitenverhältnis",
|
||||
"fullscreenButton": "Vollbild aktivieren",
|
||||
"exitFullscreenButton": "Vollbild verlassen",
|
||||
"rotationLockButton": "Drehsperre",
|
||||
"timelineSlider": "Video-Zeitleiste",
|
||||
"volumeSlider": "Lautstärkepegel",
|
||||
"backButton": "Zurück"
|
||||
},
|
||||
"userStatus": {
|
||||
"admin": "Eigentümer",
|
||||
"restricted": "Eingeschränkt",
|
||||
"protected": "Geschützt",
|
||||
"current": "AKTUELL"
|
||||
},
|
||||
"messages": {
|
||||
"markedAsWatched": "Als gesehen markiert",
|
||||
"markedAsUnwatched": "Als ungesehen markiert",
|
||||
"removedFromContinueWatching": "Aus ‚Weiterschauen‘ entfernt",
|
||||
"errorLoading": "Fehler: ${error}",
|
||||
"fileInfoNotAvailable": "Dateiinfo nicht verfügbar",
|
||||
"errorLoadingFileInfo": "Fehler beim Laden der Dateiinfo: ${error}",
|
||||
"errorLoadingSeries": "Fehler beim Laden der Serie",
|
||||
"errorLoadingSeason": "Fehler beim Laden der Staffel",
|
||||
"musicNotSupported": "Musikwiedergabe wird noch nicht unterstützt",
|
||||
"logsCleared": "Protokolle gelöscht",
|
||||
"logsCopied": "Protokolle in Zwischenablage kopiert",
|
||||
"noLogsAvailable": "Keine Protokolle verfügbar",
|
||||
"libraryScanning": "Scanne „${title}“...",
|
||||
"libraryScanStarted": "Mediathekscan gestartet für „${title}“",
|
||||
"libraryScanFailed": "Fehler beim Scannen der Mediathek: ${error}",
|
||||
"metadataRefreshing": "Metadaten werden aktualisiert für „${title}“...",
|
||||
"metadataRefreshStarted": "Metadaten-Aktualisierung gestartet für „${title}“",
|
||||
"metadataRefreshFailed": "Metadaten konnten nicht aktualisiert werden: ${error}",
|
||||
"noPlexToken": "Kein Plex-Token gefunden. Bitte erneut anmelden.",
|
||||
"logoutConfirm": "Abmeldung wirklich durchführen?",
|
||||
"noSeasonsFound": "Keine Staffeln gefunden",
|
||||
"noEpisodesFound": "Keine Episoden in der ersten Staffel gefunden",
|
||||
"noEpisodesFoundGeneral": "Keine Episoden gefunden",
|
||||
"noResultsFound": "Keine Ergebnisse gefunden",
|
||||
"sleepTimerSet": "Sleep-Timer gesetzt auf ${label}",
|
||||
"failedToSwitchProfile": "Profilwechsel zu ${displayName} fehlgeschlagen",
|
||||
"noItemsAvailable": "Keine Elemente verfügbar",
|
||||
"failedToCreatePlayQueue": "Wiedergabewarteschlange konnte nicht erstellt werden",
|
||||
"failedToCreatePlayQueueNoItems": "Wiedergabewarteschlange konnte nicht erstellt werden – keine Elemente",
|
||||
"failedPlayback": "Wiedergabe für ${action} fehlgeschlagen: ${error}"
|
||||
},
|
||||
"profile": {
|
||||
"noUsersAvailable": "Keine Benutzer verfügbar"
|
||||
},
|
||||
"subtitlingStyling": {
|
||||
"stylingOptions": "Stiloptionen",
|
||||
"fontSize": "Schriftgröße",
|
||||
"textColor": "Textfarbe",
|
||||
"borderSize": "Rahmengröße",
|
||||
"borderColor": "Rahmenfarbe",
|
||||
"backgroundOpacity": "Hintergrunddeckkraft",
|
||||
"backgroundColor": "Hintergrundfarbe"
|
||||
},
|
||||
"dialog": {
|
||||
"confirmAction": "Aktion bestätigen",
|
||||
"areYouSure": "Aktion wirklich ausführen?",
|
||||
"cancel": "Abbrechen",
|
||||
"playNow": "Jetzt abspielen"
|
||||
},
|
||||
"discover": {
|
||||
"title": "Entdecken",
|
||||
"switchProfile": "Profil wechseln",
|
||||
"switchServer": "Server wechseln",
|
||||
"logout": "Abmelden",
|
||||
"noContentAvailable": "Kein Inhalt verfügbar",
|
||||
"addMediaToLibraries": "Medien zur Mediathek hinzufügen",
|
||||
"continueWatching": "Weiterschauen",
|
||||
"recentlyAdded": "Kürzlich hinzugefügt",
|
||||
"play": "Abspielen",
|
||||
"resume": "Fortsetzen",
|
||||
"playEpisode": "S${season}, E${episode} - Abspielen",
|
||||
"resumeEpisode": "S${season}, E${episode} - Fortsetzen",
|
||||
"pause": "Pause",
|
||||
"overview": "Übersicht",
|
||||
"cast": "Besetzung",
|
||||
"seasons": "Staffeln",
|
||||
"studio": "Studio",
|
||||
"rating": "Altersfreigabe",
|
||||
"watched": "Gesehen",
|
||||
"episodeCount": "${count} Episoden",
|
||||
"watchedProgress": "${watched} von ${total} gesehen",
|
||||
"movie": "Film",
|
||||
"tvShow": "Serie",
|
||||
"minutesLeft": "${minutes} Min übrig"
|
||||
},
|
||||
"errors": {
|
||||
"searchFailed": "Suche fehlgeschlagen: ${error}",
|
||||
"connectionTimeout": "Zeitüberschreitung beim Laden von ${context}",
|
||||
"connectionFailed": "Verbindung zum Plex-Server fehlgeschlagen",
|
||||
"failedToLoad": "Fehler beim Laden von ${context}: ${error}",
|
||||
"noClientAvailable": "Kein Client verfügbar",
|
||||
"authenticationFailed": "Authentifizierung fehlgeschlagen: ${error}",
|
||||
"couldNotLaunchUrl": "Auth-URL konnte nicht geöffnet werden",
|
||||
"pleaseEnterToken": "Bitte Token eingeben",
|
||||
"invalidToken": "Ungültiges Token",
|
||||
"failedToVerifyToken": "Token-Verifizierung fehlgeschlagen: ${error}",
|
||||
"failedToSwitchProfile": "Profilwechsel zu ${displayName} fehlgeschlagen",
|
||||
"connectionFailedGeneric": "Verbindung fehlgeschlagen"
|
||||
},
|
||||
"libraries": {
|
||||
"title": "Mediatheken",
|
||||
"scanLibraryFiles": "Mediatheksdateien scannen",
|
||||
"scanLibrary": "Mediathek scannen",
|
||||
"analyze": "Analysieren",
|
||||
"analyzeLibrary": "Mediathek analysieren",
|
||||
"refreshMetadata": "Metadaten aktualisieren",
|
||||
"emptyTrash": "Papierkorb leeren",
|
||||
"emptyingTrash": "Papierkorb für „${title}“ wird geleert...",
|
||||
"trashEmptied": "Papierkorb für „${title}“ geleert",
|
||||
"failedToEmptyTrash": "Papierkorb konnte nicht geleert werden: ${error}",
|
||||
"analyzing": "Analysiere „${title}“...",
|
||||
"analysisStarted": "Analyse gestartet für „${title}“",
|
||||
"failedToAnalyze": "Analyse der Mediathek fehlgeschlagen: ${error}",
|
||||
"noLibrariesFound": "Keine Mediatheken gefunden",
|
||||
"thisLibraryIsEmpty": "Diese Mediathek ist leer",
|
||||
"all": "Alle",
|
||||
"clearAll": "Alle löschen",
|
||||
"scanLibraryConfirm": "„${title}“ wirklich scannen?",
|
||||
"analyzeLibraryConfirm": "„${title}“ wirklich analysieren?",
|
||||
"refreshMetadataConfirm": "Metadaten für „${title}“ wirklich aktualisieren?",
|
||||
"emptyTrashConfirm": "Papierkorb für „${title}“ wirklich leeren?",
|
||||
"manageLibraries": "Mediatheken verwalten",
|
||||
"sort": "Sortieren",
|
||||
"sortBy": "Sortieren nach",
|
||||
"filters": "Filter",
|
||||
"loadingLibraryWithCount": "Lade Mediathek... (${count} Elemente geladen)",
|
||||
"confirmActionMessage": "Aktion wirklich durchführen?",
|
||||
"showLibrary": "Mediathek anzeigen",
|
||||
"hideLibrary": "Mediathek ausblenden",
|
||||
"libraryOptions": "Mediatheksoptionen",
|
||||
"content": "Bibliotheksinhalt",
|
||||
"selectLibrary": "Bibliothek auswählen",
|
||||
"filtersWithCount": "Filter (${count})",
|
||||
"noRecommendations": "Keine Empfehlungen verfügbar",
|
||||
"noCollections": "Keine Sammlungen in dieser Mediathek",
|
||||
"noFoldersFound": "Keine Ordner gefunden",
|
||||
"folders": "Ordner",
|
||||
"tabs": {
|
||||
"recommended": "Empfohlen",
|
||||
"browse": "Durchsuchen",
|
||||
"collections": "Sammlungen",
|
||||
"playlists": "Wiedergabelisten"
|
||||
},
|
||||
"groupings": {
|
||||
"all": "Alle",
|
||||
"movies": "Filme",
|
||||
"shows": "Serien",
|
||||
"seasons": "Staffeln",
|
||||
"episodes": "Episoden",
|
||||
"folders": "Ordner"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"title": "Über",
|
||||
"openSourceLicenses": "Open-Source-Lizenzen",
|
||||
"versionLabel": "Version ${version}",
|
||||
"appDescription": "Ein schöner Plex-Client für Flutter",
|
||||
"viewLicensesDescription": "Lizenzen von Drittanbieter-Bibliotheken anzeigen"
|
||||
},
|
||||
"serverSelection": {
|
||||
"connectingToServer": "Verbinde mit Server...",
|
||||
"connectingToAllServers": "Verbinde mit allen Servern...",
|
||||
"connectToAllServers": "Mit allen Servern verbinden",
|
||||
"allServerConnectionsFailed": "Verbindung zu allen Servern fehlgeschlagen. Bitte Netzwerk prüfen und erneut versuchen.",
|
||||
"foundServers": "${count} Server gefunden",
|
||||
"orSelectIndividually": "Oder einzelnen Server auswählen:",
|
||||
"serverDebugCopied": "Server-Debugdaten kopiert",
|
||||
"copyDebugData": "Debugdaten kopieren",
|
||||
"noServersFound": "Keine Server gefunden",
|
||||
"malformedServerData": "${count} Server mit fehlerhaften Daten gefunden. Keine gültigen Server verfügbar.",
|
||||
"incompleteServerInfo": "Einige Serverinformationen sind unvollständig und wurden übersprungen. Plex.tv-Konto prüfen.",
|
||||
"incompleteConnectionInfo": "Serververbindungsinformationen unvollständig. Bitte erneut versuchen.",
|
||||
"malformedServerInfo": "Serverinformationen fehlerhaft: ${message}",
|
||||
"networkConnectionFailed": "Netzwerkverbindung fehlgeschlagen. Internetverbindung prüfen und erneut versuchen.",
|
||||
"authenticationFailed": "Authentifizierung fehlgeschlagen. Bitte erneut anmelden.",
|
||||
"plexServiceUnavailable": "Plex-Dienst nicht verfügbar. Bitte später erneut versuchen.",
|
||||
"failedToLoadServers": "Server konnten nicht geladen werden: ${error}"
|
||||
},
|
||||
"hubDetail": {
|
||||
"title": "Titel",
|
||||
"releaseYear": "Erscheinungsjahr",
|
||||
"dateAdded": "Hinzugefügt am",
|
||||
"rating": "Bewertung",
|
||||
"noItemsFound": "Keine Elemente gefunden"
|
||||
},
|
||||
"logs": {
|
||||
"title": "Protokolle",
|
||||
"clearLogs": "Protokolle löschen",
|
||||
"copyLogs": "Protokolle kopieren",
|
||||
"exportLogs": "Protokolle exportieren",
|
||||
"noLogsToShow": "Keine Protokolle zum Anzeigen",
|
||||
"error": "Fehler:",
|
||||
"stackTrace": "Stacktrace:"
|
||||
},
|
||||
"licenses": {
|
||||
"relatedPackages": "Verwandte Pakete",
|
||||
"license": "Lizenz",
|
||||
"licenseNumber": "Lizenz ${number}",
|
||||
"licensesCount": "${count} Lizenzen"
|
||||
},
|
||||
"navigation": {
|
||||
"home": "Start",
|
||||
"search": "Suche",
|
||||
"libraries": "Mediatheken",
|
||||
"settings": "Einstellungen"
|
||||
},
|
||||
"playlists": {
|
||||
"title": "Wiedergabelisten",
|
||||
"noPlaylists": "Keine Wiedergabelisten gefunden",
|
||||
"create": "Wiedergabeliste erstellen",
|
||||
"newPlaylist": "Neue Wiedergabeliste",
|
||||
"playlistName": "Name der Wiedergabeliste",
|
||||
"enterPlaylistName": "Name der Wiedergabeliste eingeben",
|
||||
"edit": "Wiedergabeliste bearbeiten",
|
||||
"delete": "Wiedergabeliste löschen",
|
||||
"addTo": "Zur Wiedergabeliste hinzufügen",
|
||||
"addItems": "Elemente hinzufügen",
|
||||
"removeItem": "Aus Wiedergabeliste entfernen",
|
||||
"clearPlaylist": "Wiedergabeliste leeren",
|
||||
"playAll": "Alle abspielen",
|
||||
"shuffle": "Zufällig",
|
||||
"smartPlaylist": "Intelligente Wiedergabeliste",
|
||||
"regularPlaylist": "Normale Wiedergabeliste",
|
||||
"itemCount": "${count} Elemente",
|
||||
"oneItem": "1 Element",
|
||||
"emptyPlaylist": "Diese Wiedergabeliste ist leer",
|
||||
"deleteConfirm": "Wiedergabeliste löschen?",
|
||||
"deleteMessage": "Soll \"${name}\" wirklich gelöscht werden?",
|
||||
"created": "Wiedergabeliste erstellt",
|
||||
"updated": "Wiedergabeliste aktualisiert",
|
||||
"deleted": "Wiedergabeliste gelöscht",
|
||||
"itemAdded": "Zur Wiedergabeliste hinzugefügt",
|
||||
"itemRemoved": "Aus Wiedergabeliste entfernt",
|
||||
"selectPlaylist": "Wiedergabeliste auswählen",
|
||||
"createNewPlaylist": "Neue Wiedergabeliste erstellen",
|
||||
"errorCreating": "Wiedergabeliste konnte nicht erstellt werden",
|
||||
"errorDeleting": "Wiedergabeliste konnte nicht gelöscht werden",
|
||||
"errorLoading": "Wiedergabelisten konnten nicht geladen werden",
|
||||
"errorAdding": "Konnte nicht zur Wiedergabeliste hinzugefügt werden",
|
||||
"errorReordering": "Element der Wiedergabeliste konnte nicht neu geordnet werden",
|
||||
"errorRemoving": "Konnte nicht aus der Wiedergabeliste entfernt werden",
|
||||
"playlist": "Wiedergabeliste"
|
||||
},
|
||||
"collections": {
|
||||
"title": "Sammlungen",
|
||||
"collection": "Sammlung",
|
||||
"empty": "Sammlung ist leer",
|
||||
"noItems": "Keine Elemente in dieser Sammlung",
|
||||
"unknownLibrarySection": "Löschen nicht möglich: Unbekannte Bibliothekssektion",
|
||||
"deleteCollection": "Sammlung löschen",
|
||||
"deleteConfirm": "Sind Sie sicher, dass Sie \"${title}\" löschen möchten? Dies kann nicht rückgängig gemacht werden.",
|
||||
"deleted": "Sammlung gelöscht",
|
||||
"deleteFailed": "Sammlung konnte nicht gelöscht werden",
|
||||
"deleteFailedWithError": "Sammlung konnte nicht gelöscht werden: ${error}",
|
||||
"failedToLoadItems": "Sammlungselemente konnten nicht geladen werden: ${error}",
|
||||
"addTo": "Zur Sammlung hinzufügen",
|
||||
"selectCollection": "Sammlung auswählen",
|
||||
"createNewCollection": "Neue Sammlung erstellen",
|
||||
"collectionName": "Sammlungsname",
|
||||
"enterCollectionName": "Sammlungsnamen eingeben",
|
||||
"addedToCollection": "Zur Sammlung hinzugefügt",
|
||||
"errorAddingToCollection": "Fehler beim Hinzufügen zur Sammlung",
|
||||
"created": "Sammlung erstellt",
|
||||
"removeFromCollection": "Aus Sammlung entfernen",
|
||||
"removeFromCollectionConfirm": "\"${title}\" aus dieser Sammlung entfernen?",
|
||||
"removedFromCollection": "Aus Sammlung entfernt",
|
||||
"removeFromCollectionFailed": "Entfernen aus Sammlung fehlgeschlagen",
|
||||
"removeFromCollectionError": "Fehler beim Entfernen aus der Sammlung: ${error}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
{
|
||||
"app": {
|
||||
"title": "Plezy",
|
||||
"loading": "Caricamento..."
|
||||
},
|
||||
"auth": {
|
||||
"signInWithPlex": "Accedi con Plex",
|
||||
"showQRCode": "Mostra QR Code",
|
||||
"cancel": "Cancella",
|
||||
"authenticate": "Autenticazione",
|
||||
"retry": "Riprova",
|
||||
"debugEnterToken": "Debug: Inserisci Token Plex",
|
||||
"plexTokenLabel": "Token Auth Plex",
|
||||
"plexTokenHint": "Inserisci il tuo token di Plex.tv",
|
||||
"authenticationTimeout": "Autenticazione scaduta. Riprova.",
|
||||
"scanQRCodeInstruction": "Scansiona questo QR code con un dispositivo connesso a Plex per autenticarti.",
|
||||
"waitingForAuth": "In attesa di autenticazione...\nCompleta l'accesso dal tuo browser."
|
||||
},
|
||||
"common": {
|
||||
"cancel": "Cancella",
|
||||
"save": "Salva",
|
||||
"close": "Chiudi",
|
||||
"clear": "Pulisci",
|
||||
"reset": "Ripristina",
|
||||
"later": "Più tardi",
|
||||
"submit": "Invia",
|
||||
"confirm": "Conferma",
|
||||
"retry": "Riprova",
|
||||
"playNow": "Riproduci ora",
|
||||
"logout": "Disconnetti",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"owned": "Di proprietà",
|
||||
"shared": "Condiviso",
|
||||
"current": "CORRENTE",
|
||||
"unknown": "Sconosciuto",
|
||||
"refresh": "Aggiorna",
|
||||
"yes": "Sì",
|
||||
"no": "No",
|
||||
"server": "Server",
|
||||
"delete": "Elimina",
|
||||
"shuffle": "Casuale",
|
||||
"addTo": "Aggiungi a..."
|
||||
},
|
||||
"screens": {
|
||||
"licenses": "Licenze",
|
||||
"selectServer": "Seleziona server",
|
||||
"switchProfile": "Cambia profilo",
|
||||
"subtitleStyling": "Stile sottotitoli",
|
||||
"search": "Cerca",
|
||||
"logs": "Registro"
|
||||
},
|
||||
"update": {
|
||||
"available": "Aggiornamento disponibile",
|
||||
"versionAvailable": "Versione ${version} disponibile",
|
||||
"currentVersion": "Corrente: ${version}",
|
||||
"skipVersion": "Salta questa versione",
|
||||
"viewRelease": "Visualizza dettagli release",
|
||||
"latestVersion": "La versione installata è l'ultima disponibile",
|
||||
"checkFailed": "Impossibile controllare gli aggiornamenti"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Impostazioni",
|
||||
"language": "Lingua",
|
||||
"theme": "Tema",
|
||||
"appearance": "Aspetto",
|
||||
"videoPlayback": "Riproduzione video",
|
||||
"shufflePlay": "Riproduzione casuale",
|
||||
"advanced": "Avanzate",
|
||||
"useSeasonPostersDescription": "Mostra il poster della stagione invece del poster della serie per gli episodi",
|
||||
"showHeroSectionDescription": "Visualizza il carosello dei contenuti in primo piano sulla schermata iniziale",
|
||||
"secondsLabel": "Secondi",
|
||||
"minutesLabel": "Minuti",
|
||||
"secondsShort": "s",
|
||||
"minutesShort": "m",
|
||||
"durationHint": "Inserisci durata (${min}-${max})",
|
||||
"systemTheme": "Sistema",
|
||||
"systemThemeDescription": "Segui le impostazioni di sistema",
|
||||
"lightTheme": "Chiaro",
|
||||
"darkTheme": "Scuro",
|
||||
"libraryDensity": "Densità libreria",
|
||||
"compact": "Compatta",
|
||||
"compactDescription": "Schede più piccole, più elementi visibili",
|
||||
"normal": "Normale",
|
||||
"normalDescription": "Dimensione predefinita",
|
||||
"comfortable": "Comoda",
|
||||
"comfortableDescription": "Schede più grandi, meno elementi visibili",
|
||||
"viewMode": "Modalità di visualizzazione",
|
||||
"gridView": "Griglia",
|
||||
"gridViewDescription": "Visualizza gli elementi in un layout a griglia",
|
||||
"listView": "Elenco",
|
||||
"listViewDescription": "Visualizza gli elementi in un layout a elenco",
|
||||
"useSeasonPosters": "Usa poster delle stagioni",
|
||||
"showHeroSection": "Mostra sezione principale",
|
||||
"hardwareDecoding": "Decodifica Hardware",
|
||||
"hardwareDecodingDescription": "Utilizza l'accelerazione hardware quando disponibile",
|
||||
"bufferSize": "Dimensione buffer",
|
||||
"bufferSizeMB": "${size}MB",
|
||||
"subtitleStyling": "Stile sottotitoli",
|
||||
"subtitleStylingDescription": "Personalizza l'aspetto dei sottotitoli",
|
||||
"smallSkipDuration": "Durata skip breve",
|
||||
"largeSkipDuration": "Durata skip lungo",
|
||||
"secondsUnit": "${seconds} secondi",
|
||||
"defaultSleepTimer": "Timer spegnimento predefinito",
|
||||
"minutesUnit": "${minutes} minuti",
|
||||
"rememberTrackSelections": "Ricorda selezioni tracce per serie/film",
|
||||
"rememberTrackSelectionsDescription": "Salva automaticamente le preferenze delle lingue audio e sottotitoli quando cambi tracce durante la riproduzione",
|
||||
"unwatchedOnly": "Solo non guardati",
|
||||
"unwatchedOnlyDescription": "Includi solo gli episodi non guardati nella coda di riproduzione casuale",
|
||||
"shuffleOrderNavigation": "Navigazione in ordine casuale",
|
||||
"shuffleOrderNavigationDescription": "I pulsanti Avanti/Indietro seguono l'ordine casuale",
|
||||
"loopShuffleQueue": "Coda di riproduzione casuale in loop",
|
||||
"loopShuffleQueueDescription": "Riavvia la coda quando raggiungi la fine",
|
||||
"videoPlayerControls": "Controlli del lettore video",
|
||||
"keyboardShortcuts": "Scorciatoie da tastiera",
|
||||
"keyboardShortcutsDescription": "Personalizza le scorciatoie da tastiera",
|
||||
"debugLogging": "Log di debug",
|
||||
"debugLoggingDescription": "Abilita il logging dettagliato per la risoluzione dei problemi",
|
||||
"viewLogs": "Visualizza log",
|
||||
"viewLogsDescription": "Visualizza i log dell'applicazione",
|
||||
"clearCache": "Svuota cache",
|
||||
"clearCacheDescription": "Questa opzione cancellerà tutte le immagini e i dati memorizzati nella cache. Dopo aver cancellato la cache, l'app potrebbe impiegare più tempo per caricare i contenuti.",
|
||||
"clearCacheSuccess": "Cache cancellata correttamente",
|
||||
"resetSettings": "Ripristina impostazioni",
|
||||
"resetSettingsDescription": "Questa opzione ripristinerà tutte le impostazioni ai valori predefiniti. Non può essere annullata.",
|
||||
"resetSettingsSuccess": "Impostazioni ripristinate correttamente",
|
||||
"shortcutsReset": "Scorciatoie ripristinate alle impostazioni predefinite",
|
||||
"about": "Informazioni",
|
||||
"aboutDescription": "Informazioni sull'app e le licenze",
|
||||
"updates": "Aggiornamenti",
|
||||
"updateAvailable": "Aggiornamento disponibile",
|
||||
"checkForUpdates": "Controlla aggiornamenti",
|
||||
"validationErrorEnterNumber": "Inserisci un numero valido",
|
||||
"validationErrorDuration": "la durata deve essere compresa tra ${min} e ${max} ${unit}",
|
||||
"shortcutAlreadyAssigned": "Scorciatoia già assegnata a ${action}",
|
||||
"shortcutUpdated": "Scorciatoia aggiornata per ${action}"
|
||||
},
|
||||
"search": {
|
||||
"hint": "Cerca film. spettacoli, musica...",
|
||||
"tryDifferentTerm": "Prova altri termini di ricerca",
|
||||
"searchYourMedia": "Cerca nei tuoi media",
|
||||
"enterTitleActorOrKeyword": "Inserisci un titolo, attore o parola chiave"
|
||||
},
|
||||
"hotkeys": {
|
||||
"setShortcutFor": "Imposta scorciatoia per ${actionName}",
|
||||
"clearShortcut": "Elimina scorciatoia"
|
||||
},
|
||||
"pinEntry": {
|
||||
"enterPin": "Inserisci PIN",
|
||||
"showPin": "Mostra PIN",
|
||||
"hidePin": "Nascondi PIN"
|
||||
},
|
||||
"fileInfo": {
|
||||
"title": "Info sul file",
|
||||
"video": "Video",
|
||||
"audio": "Audio",
|
||||
"file": "File",
|
||||
"advanced": "Avanzate",
|
||||
"codec": "Codec",
|
||||
"resolution": "Risoluzione",
|
||||
"bitrate": "Bitrate",
|
||||
"frameRate": "Frame Rate",
|
||||
"aspectRatio": "Aspect Ratio",
|
||||
"profile": "Profilo",
|
||||
"bitDepth": "Profondità colore",
|
||||
"colorSpace": "Spazio colore",
|
||||
"colorRange": "Gamma colori",
|
||||
"colorPrimaries": "Colori primari",
|
||||
"chromaSubsampling": "Sottocampionamento cromatico",
|
||||
"channels": "Canali",
|
||||
"path": "Percorso",
|
||||
"size": "Dimensione",
|
||||
"container": "Contenitore",
|
||||
"duration": "Durata",
|
||||
"optimizedForStreaming": "Ottimizzato per lo streaming",
|
||||
"has64bitOffsets": "Offset a 64-bit"
|
||||
},
|
||||
"mediaMenu": {
|
||||
"markAsWatched": "Segna come visto",
|
||||
"markAsUnwatched": "Segna come non visto",
|
||||
"removeFromContinueWatching": "Rimuovi da Continua a guardare",
|
||||
"goToSeries": "Vai alle serie",
|
||||
"goToSeason": "Vai alla stagione",
|
||||
"shufflePlay": "Riproduzione casuale",
|
||||
"fileInfo": "Info sul file"
|
||||
},
|
||||
"accessibility": {
|
||||
"mediaCardMovie": "${title}, film",
|
||||
"mediaCardShow": "${title}, serie TV",
|
||||
"mediaCardEpisode": "${title}, ${episodeInfo}",
|
||||
"mediaCardSeason": "${title}, ${seasonInfo}",
|
||||
"mediaCardWatched": "visto",
|
||||
"mediaCardPartiallyWatched": "${percent} percento visto",
|
||||
"mediaCardUnwatched": "non visto",
|
||||
"tapToPlay": "Tocca per riprodurre"
|
||||
},
|
||||
"tooltips": {
|
||||
"shufflePlay": "Riproduzione casuale",
|
||||
"markAsWatched": "Segna come visto",
|
||||
"markAsUnwatched": "Segna come non visto"
|
||||
},
|
||||
"videoControls": {
|
||||
"audioLabel": "Audio",
|
||||
"subtitlesLabel": "Sottotitoli",
|
||||
"resetToZero": "Riporta a 0ms",
|
||||
"addTime": "+${amount}${unit}",
|
||||
"minusTime": "-${amount}${unit}",
|
||||
"playsLater": "${label} riprodotto dopo",
|
||||
"playsEarlier": "${label} riprodotto prima",
|
||||
"noOffset": "Nessun offset",
|
||||
"letterbox": "Letterbox",
|
||||
"fillScreen": "Riempi schermo",
|
||||
"stretch": "Allunga",
|
||||
"lockRotation": "Blocca rotazione",
|
||||
"unlockRotation": "Sblocca rotazione",
|
||||
"sleepTimer": "Timer di spegnimento",
|
||||
"timerActive": "Timer attivo",
|
||||
"playbackWillPauseIn": "La riproduzione si interromperà tra ${duration}",
|
||||
"sleepTimerCompleted": "Timer di spegnimento completato - riproduzione in pausa",
|
||||
"playButton": "Riproduci",
|
||||
"pauseButton": "Pausa",
|
||||
"seekBackwardButton": "Riavvolgi di ${seconds} secondi",
|
||||
"seekForwardButton": "Avanza di ${seconds} secondi",
|
||||
"previousButton": "Episodio precedente",
|
||||
"nextButton": "Episodio successivo",
|
||||
"previousChapterButton": "Capitolo precedente",
|
||||
"nextChapterButton": "Capitolo successivo",
|
||||
"volumeButton": "Volume",
|
||||
"muteButton": "Silenzia",
|
||||
"unmuteButton": "Riattiva audio",
|
||||
"settingsButton": "Impostazioni video",
|
||||
"audioTrackButton": "Tracce audio",
|
||||
"subtitlesButton": "Sottotitoli",
|
||||
"chaptersButton": "Capitoli",
|
||||
"versionsButton": "Versioni video",
|
||||
"aspectRatioButton": "Proporzioni",
|
||||
"fullscreenButton": "Attiva schermo intero",
|
||||
"exitFullscreenButton": "Esci da schermo intero",
|
||||
"rotationLockButton": "Blocco rotazione",
|
||||
"timelineSlider": "Timeline video",
|
||||
"volumeSlider": "Livello volume",
|
||||
"backButton": "Indietro"
|
||||
},
|
||||
"userStatus": {
|
||||
"admin": "Admin",
|
||||
"restricted": "Limitato",
|
||||
"protected": "Protetto",
|
||||
"current": "ATTUALE"
|
||||
},
|
||||
"messages": {
|
||||
"markedAsWatched": "Segna come visto",
|
||||
"markedAsUnwatched": "Segna come non visto",
|
||||
"removedFromContinueWatching": "Rimosso da Continua a guardare",
|
||||
"errorLoading": "Errore: ${error}",
|
||||
"fileInfoNotAvailable": "Informazioni sul file non disponibili",
|
||||
"errorLoadingFileInfo": "Errore caricamento informazioni sul file: ${error}",
|
||||
"errorLoadingSeries": "Errore caricamento serie",
|
||||
"errorLoadingSeason": "Errore caricamento stagione",
|
||||
"musicNotSupported": "La riproduzione musicale non è ancora supportata",
|
||||
"logsCleared": "Log eliminati",
|
||||
"logsCopied": "Log copiati negli appunti",
|
||||
"noLogsAvailable": "Nessun log disponibile",
|
||||
"libraryScanning": "Scansione \"${title}\"...",
|
||||
"libraryScanStarted": "Scansione libreria iniziata per \"${title}\"",
|
||||
"libraryScanFailed": "Impossibile eseguire scansione della libreria: ${error}",
|
||||
"metadataRefreshing": "Aggiornamento metadati per \"${title}\"...",
|
||||
"metadataRefreshStarted": "Aggiornamento metadati per \"${title}\"",
|
||||
"metadataRefreshFailed": "Errore aggiornamento metadati: ${error}",
|
||||
"noPlexToken": "Nessun token Plex trovato. Riesegui l'accesso.",
|
||||
"logoutConfirm": "Sei sicuro di volerti disconnettere?",
|
||||
"noSeasonsFound": "Nessuna stagione trovata",
|
||||
"noEpisodesFound": "Nessun episodio trovato nella prima stagione",
|
||||
"noEpisodesFoundGeneral": "Nessun episodio trovato",
|
||||
"noResultsFound": "Nessun risultato",
|
||||
"sleepTimerSet": "Imposta timer spegnimento per ${label}",
|
||||
"failedToSwitchProfile": "Impossibile passare a ${displayName}",
|
||||
"noItemsAvailable": "Nessun elemento disponibile",
|
||||
"failedToCreatePlayQueue": "Impossibile creare la coda di riproduzione",
|
||||
"failedToCreatePlayQueueNoItems": "Impossibile creare la coda di riproduzione - nessun elemento",
|
||||
"failedPlayback": "Impossibile ${action}: ${error}"
|
||||
},
|
||||
"profile": {
|
||||
"noUsersAvailable": "Nessun utente disponibile"
|
||||
},
|
||||
"subtitlingStyling": {
|
||||
"stylingOptions": "Opzioni stile",
|
||||
"fontSize": "Dimensione",
|
||||
"textColor": "Colore testo",
|
||||
"borderSize": "Dimensione bordo",
|
||||
"borderColor": "Colore bordo",
|
||||
"backgroundOpacity": "Opacità sfondo",
|
||||
"backgroundColor": "Colore sfondo"
|
||||
},
|
||||
"dialog": {
|
||||
"confirmAction": "Conferma azione",
|
||||
"areYouSure": "Sei sicuro di voler eseguire questa azione?",
|
||||
"cancel": "Cancella",
|
||||
"playNow": "Riproduci ora"
|
||||
},
|
||||
"discover": {
|
||||
"title": "Esplora",
|
||||
"switchProfile": "Cambia profilo",
|
||||
"switchServer": "Cambia server",
|
||||
"logout": "Disconnetti",
|
||||
"noContentAvailable": "Nessun contenuto disponibile",
|
||||
"addMediaToLibraries": "Aggiungi alcuni file multimediali alle tue librerie",
|
||||
"continueWatching": "Continua a guardare",
|
||||
"recentlyAdded": "Aggiunti di recente",
|
||||
"play": "Riproduci",
|
||||
"resume": "Riprendi",
|
||||
"playEpisode": "Riproduci S${season}, E${episode}",
|
||||
"resumeEpisode": "Riprendi S${season}, E${episode}",
|
||||
"pause": "Pausa",
|
||||
"overview": "Panoramica",
|
||||
"cast": "Attori",
|
||||
"seasons": "Stagioni",
|
||||
"studio": "Studio",
|
||||
"rating": "Classificazione",
|
||||
"watched": "Guardato",
|
||||
"episodeCount": "${count} episodi",
|
||||
"watchedProgress": "${watched}/${total} guardati",
|
||||
"movie": "Film",
|
||||
"tvShow": "Serie TV",
|
||||
"minutesLeft": "${minutes} minuti rimanenti"
|
||||
},
|
||||
"errors": {
|
||||
"searchFailed": "Ricerca fallita: ${error}",
|
||||
"connectionTimeout": "Timeout connessione durante caricamento di ${context}",
|
||||
"connectionFailed": "Impossibile connettersi al server Plex.",
|
||||
"failedToLoad": "Impossibile caricare ${context}: ${error}",
|
||||
"noClientAvailable": "Nessun client disponibile",
|
||||
"authenticationFailed": "Autenticazione fallita: ${error}",
|
||||
"couldNotLaunchUrl": "Impossibile avviare URL di autenticazione",
|
||||
"pleaseEnterToken": "Inserisci token",
|
||||
"invalidToken": "Token non valido",
|
||||
"failedToVerifyToken": "Verifica token fallita: ${error}",
|
||||
"failedToSwitchProfile": "Impossibile passare a ${displayName}",
|
||||
"connectionFailedGeneric": "Connessione fallita"
|
||||
},
|
||||
"libraries": {
|
||||
"title": "Librerie",
|
||||
"scanLibraryFiles": "Scansiona file libreria",
|
||||
"scanLibrary": "Scansiona libreria",
|
||||
"analyze": "Analizza",
|
||||
"analyzeLibrary": "Analizza libreria",
|
||||
"refreshMetadata": "Aggiorna metadati",
|
||||
"emptyTrash": "Svuota cestino",
|
||||
"emptyingTrash": "Svuotamento cestino per \"${title}\"...",
|
||||
"trashEmptied": "Cestino svuotato per \"${title}\"",
|
||||
"failedToEmptyTrash": "Impossibile svuotare cestino: ${error}",
|
||||
"analyzing": "Analisi \"${title}\"...",
|
||||
"analysisStarted": "Analisi iniziata per \"${title}\"",
|
||||
"failedToAnalyze": "Impossibile analizzare libreria: ${error}",
|
||||
"noLibrariesFound": "Nessuna libreria trovata",
|
||||
"thisLibraryIsEmpty": "Questa libreria è vuota",
|
||||
"all": "Tutto",
|
||||
"clearAll": "Cancella tutto",
|
||||
"scanLibraryConfirm": "Sei sicuro di voler scansionare \"${title}\"?",
|
||||
"analyzeLibraryConfirm": "Sei sicuro di voler analizzare \"${title}\"?",
|
||||
"refreshMetadataConfirm": "Sei sicuro di voler aggiornare i metadati per \"${title}\"?",
|
||||
"emptyTrashConfirm": "Sei sicuro di voler svuotare il cestino per \"${title}\"?",
|
||||
"manageLibraries": "Gestisci librerie",
|
||||
"sort": "Ordina",
|
||||
"sortBy": "Ordina per",
|
||||
"filters": "Filtri",
|
||||
"loadingLibraryWithCount": "Caricamento librerie... (${count} oggetti caricati)",
|
||||
"confirmActionMessage": "Sei sicuro di voler eseguire questa azione?",
|
||||
"showLibrary": "Mostra libreria",
|
||||
"hideLibrary": "Nascondi libreria",
|
||||
"libraryOptions": "Opzioni libreria",
|
||||
"content": "contenuto della libreria",
|
||||
"selectLibrary": "Seleziona libreria",
|
||||
"filtersWithCount": "Filtri (${count})",
|
||||
"noRecommendations": "Nessun consiglio disponibile",
|
||||
"noCollections": "Nessuna raccolta in questa libreria",
|
||||
"noFoldersFound": "Nessuna cartella trovata",
|
||||
"folders": "cartelle",
|
||||
"tabs": {
|
||||
"recommended": "Consigliati",
|
||||
"browse": "Esplora",
|
||||
"collections": "Raccolte",
|
||||
"playlists": "Playlist"
|
||||
},
|
||||
"groupings": {
|
||||
"all": "Tutti",
|
||||
"movies": "Film",
|
||||
"shows": "Serie TV",
|
||||
"seasons": "Stagioni",
|
||||
"episodes": "Episodi",
|
||||
"folders": "Cartelle"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"title": "Informazioni",
|
||||
"openSourceLicenses": "Licenze Open Source",
|
||||
"versionLabel": "Versione ${version}",
|
||||
"appDescription": "Un bellissimo client Plex per Flutter",
|
||||
"viewLicensesDescription": "Visualizza le licenze delle librerie di terze parti"
|
||||
},
|
||||
"serverSelection": {
|
||||
"connectingToServer": "Connessione al server...",
|
||||
"connectingToAllServers": "Connessione a tutti i server...",
|
||||
"connectToAllServers": "Connetti a tutti i server",
|
||||
"allServerConnectionsFailed": "Impossibile connettersi a nessun server. Controlla la tua rete e riprova.",
|
||||
"foundServers": "Trovati ${count} server",
|
||||
"orSelectIndividually": "Oppure seleziona un server singolarmente:",
|
||||
"serverDebugCopied": "Dati di debug del server copiati negli appunti",
|
||||
"copyDebugData": "Copia dati di debug",
|
||||
"noServersFound": "Nessun server trovato",
|
||||
"malformedServerData": "Trovato ${count} server con dati difettosi. Nessun server valido disponibile.",
|
||||
"incompleteServerInfo": "Alcuni server presentano informazioni incomplete e sono stati ignorati. Controlla il tuo account Plex.tv.",
|
||||
"incompleteConnectionInfo": "Le informazioni di connessione al server sono incomplete. Riprova.",
|
||||
"malformedServerInfo": "Le informazioni sul server sono errate: ${message}",
|
||||
"networkConnectionFailed": "Connessione di rete non riuscita. Controlla la tua connessione Internet e riprova.",
|
||||
"authenticationFailed": "Autenticazione fallita. Effettua nuovamente l'accesso.",
|
||||
"plexServiceUnavailable": "Servizio Plex non disponibile. Riprova più tardi.",
|
||||
"failedToLoadServers": "Impossibile caricare i server: ${error}"
|
||||
},
|
||||
"hubDetail": {
|
||||
"title": "Titolo",
|
||||
"releaseYear": "Anno rilascio",
|
||||
"dateAdded": "Data aggiunta",
|
||||
"rating": "Valutazione",
|
||||
"noItemsFound": "Nessun elemento trovato"
|
||||
},
|
||||
"logs": {
|
||||
"title": "Registro",
|
||||
"clearLogs": "Cancella log",
|
||||
"copyLogs": "Copia log",
|
||||
"exportLogs": "Esporta log",
|
||||
"noLogsToShow": "Nessun log da mostrare",
|
||||
"error": "Errore:",
|
||||
"stackTrace": "Traccia dello stack:"
|
||||
},
|
||||
"licenses": {
|
||||
"relatedPackages": "Pacchetti correlati",
|
||||
"license": "Licenza",
|
||||
"licenseNumber": "Licenza ${number}",
|
||||
"licensesCount": "${count} licenze"
|
||||
},
|
||||
"navigation": {
|
||||
"home": "Home",
|
||||
"search": "Cerca",
|
||||
"libraries": "Librerie",
|
||||
"settings": "Impostazioni"
|
||||
},
|
||||
"playlists": {
|
||||
"title": "Playlist",
|
||||
"noPlaylists": "Nessuna playlist trovata",
|
||||
"create": "Crea playlist",
|
||||
"newPlaylist": "Nuova playlist",
|
||||
"playlistName": "Nome playlist",
|
||||
"enterPlaylistName": "Inserisci nome playlist",
|
||||
"edit": "Modifica playlist",
|
||||
"delete": "Elimina playlist",
|
||||
"addTo": "Aggiungi a playlist",
|
||||
"addItems": "Aggiungi elementi",
|
||||
"removeItem": "Rimuovi da playlist",
|
||||
"clearPlaylist": "Svuota playlist",
|
||||
"playAll": "Riproduci tutto",
|
||||
"shuffle": "Casuale",
|
||||
"smartPlaylist": "Playlist intelligente",
|
||||
"regularPlaylist": "Playlist normale",
|
||||
"itemCount": "${count} elementi",
|
||||
"oneItem": "1 elemento",
|
||||
"emptyPlaylist": "Questa playlist è vuota",
|
||||
"deleteConfirm": "Eliminare playlist?",
|
||||
"deleteMessage": "Sei sicuro di voler eliminare \"${name}\"?",
|
||||
"created": "Playlist creata",
|
||||
"updated": "Playlist aggiornata",
|
||||
"deleted": "Playlist eliminata",
|
||||
"itemAdded": "Aggiunto alla playlist",
|
||||
"itemRemoved": "Rimosso dalla playlist",
|
||||
"selectPlaylist": "Seleziona playlist",
|
||||
"createNewPlaylist": "Crea nuova playlist",
|
||||
"errorCreating": "Errore durante la creazione della playlist",
|
||||
"errorDeleting": "Errore durante l'eliminazione della playlist",
|
||||
"errorLoading": "Errore durante il caricamento delle playlist",
|
||||
"errorAdding": "Errore durante l'aggiunta alla playlist",
|
||||
"errorReordering": "Errore durante il riordino dell'elemento della playlist",
|
||||
"errorRemoving": "Errore durante la rimozione dalla playlist",
|
||||
"playlist": "Playlist"
|
||||
},
|
||||
"collections": {
|
||||
"title": "Raccolte",
|
||||
"collection": "Raccolta",
|
||||
"empty": "La raccolta è vuota",
|
||||
"noItems": "Nessun elemento in questa raccolta",
|
||||
"unknownLibrarySection": "Impossibile eliminare: sezione libreria sconosciuta",
|
||||
"deleteCollection": "Elimina raccolta",
|
||||
"deleteConfirm": "Sei sicuro di voler eliminare \"${title}\"? Questa azione non può essere annullata.",
|
||||
"deleted": "Raccolta eliminata",
|
||||
"deleteFailed": "Impossibile eliminare la raccolta",
|
||||
"deleteFailedWithError": "Impossibile eliminare la raccolta: ${error}",
|
||||
"failedToLoadItems": "Impossibile caricare gli elementi della raccolta: ${error}",
|
||||
"addTo": "Aggiungi alla raccolta",
|
||||
"selectCollection": "Seleziona raccolta",
|
||||
"createNewCollection": "Crea nuova raccolta",
|
||||
"collectionName": "Nome raccolta",
|
||||
"enterCollectionName": "Inserisci nome raccolta",
|
||||
"addedToCollection": "Aggiunto alla raccolta",
|
||||
"errorAddingToCollection": "Errore nell'aggiunta alla raccolta",
|
||||
"created": "Raccolta creata",
|
||||
"removeFromCollection": "Rimuovi dalla raccolta",
|
||||
"removeFromCollectionConfirm": "Rimuovere \"${title}\" da questa raccolta?",
|
||||
"removedFromCollection": "Rimosso dalla raccolta",
|
||||
"removeFromCollectionFailed": "Impossibile rimuovere dalla raccolta",
|
||||
"removeFromCollectionError": "Errore durante la rimozione dalla raccolta: ${error}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
{
|
||||
"app": {
|
||||
"title": "Plezy",
|
||||
"loading": "Laden..."
|
||||
},
|
||||
"auth": {
|
||||
"signInWithPlex": "Inloggen met Plex",
|
||||
"showQRCode": "Toon QR-code",
|
||||
"cancel": "Annuleren",
|
||||
"authenticate": "Authenticeren",
|
||||
"retry": "Opnieuw proberen",
|
||||
"debugEnterToken": "Debug: Voer Plex Token in",
|
||||
"plexTokenLabel": "Plex Authenticatietoken",
|
||||
"plexTokenHint": "Voer je Plex.tv token in",
|
||||
"authenticationTimeout": "Authenticatie verlopen. Probeer opnieuw.",
|
||||
"scanQRCodeInstruction": "Scan deze QR-code met een apparaat dat is ingelogd op Plex om te authenticeren.",
|
||||
"waitingForAuth": "Wachten op authenticatie...\nVoltooi het inloggen in je browser."
|
||||
},
|
||||
"common": {
|
||||
"cancel": "Annuleren",
|
||||
"save": "Opslaan",
|
||||
"close": "Sluiten",
|
||||
"clear": "Wissen",
|
||||
"reset": "Resetten",
|
||||
"later": "Later",
|
||||
"submit": "Verzenden",
|
||||
"confirm": "Bevestigen",
|
||||
"retry": "Opnieuw proberen",
|
||||
"playNow": "Nu afspelen",
|
||||
"logout": "Uitloggen",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"owned": "Eigendom",
|
||||
"shared": "Gedeeld",
|
||||
"current": "HUIDIG",
|
||||
"unknown": "Onbekend",
|
||||
"refresh": "Vernieuwen",
|
||||
"yes": "Ja",
|
||||
"no": "Nee",
|
||||
"server": "Server",
|
||||
"delete": "Verwijderen",
|
||||
"shuffle": "Willekeurig",
|
||||
"addTo": "Toevoegen aan..."
|
||||
},
|
||||
"screens": {
|
||||
"licenses": "Licenties",
|
||||
"selectServer": "Selecteer server",
|
||||
"switchProfile": "Wissel van profiel",
|
||||
"subtitleStyling": "Ondertitel opmaak",
|
||||
"search": "Zoeken",
|
||||
"logs": "Logbestanden"
|
||||
},
|
||||
"update": {
|
||||
"available": "Update beschikbaar",
|
||||
"versionAvailable": "Versie ${version} is beschikbaar",
|
||||
"currentVersion": "Huidig: ${version}",
|
||||
"skipVersion": "Deze versie overslaan",
|
||||
"viewRelease": "Bekijk release",
|
||||
"latestVersion": "Je hebt de nieuwste versie",
|
||||
"checkFailed": "Kon niet controleren op updates"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Instellingen",
|
||||
"language": "Taal",
|
||||
"theme": "Thema",
|
||||
"appearance": "Uiterlijk",
|
||||
"videoPlayback": "Video afspelen",
|
||||
"shufflePlay": "Willekeurig afspelen",
|
||||
"advanced": "Geavanceerd",
|
||||
"useSeasonPostersDescription": "Toon seizoenposter in plaats van serieposter voor afleveringen",
|
||||
"showHeroSectionDescription": "Toon uitgelichte inhoud carrousel op startscherm",
|
||||
"secondsLabel": "Seconden",
|
||||
"minutesLabel": "Minuten",
|
||||
"secondsShort": "s",
|
||||
"minutesShort": "m",
|
||||
"durationHint": "Voer duur in (${min}-${max})",
|
||||
"systemTheme": "Systeem",
|
||||
"systemThemeDescription": "Volg systeeminstellingen",
|
||||
"lightTheme": "Licht",
|
||||
"darkTheme": "Donker",
|
||||
"libraryDensity": "Bibliotheek dichtheid",
|
||||
"compact": "Compact",
|
||||
"compactDescription": "Kleinere kaarten, meer items zichtbaar",
|
||||
"normal": "Normaal",
|
||||
"normalDescription": "Standaard grootte",
|
||||
"comfortable": "Comfortabel",
|
||||
"comfortableDescription": "Grotere kaarten, minder items zichtbaar",
|
||||
"viewMode": "Weergavemodus",
|
||||
"gridView": "Raster",
|
||||
"gridViewDescription": "Items weergeven in een rasterindeling",
|
||||
"listView": "Lijst",
|
||||
"listViewDescription": "Items weergeven in een lijstindeling",
|
||||
"useSeasonPosters": "Gebruik seizoenposters",
|
||||
"showHeroSection": "Toon hoofdsectie",
|
||||
"hardwareDecoding": "Hardware decodering",
|
||||
"hardwareDecodingDescription": "Gebruik hardware versnelling indien beschikbaar",
|
||||
"bufferSize": "Buffer grootte",
|
||||
"bufferSizeMB": "${size}MB",
|
||||
"subtitleStyling": "Ondertitel opmaak",
|
||||
"subtitleStylingDescription": "Pas ondertitel uiterlijk aan",
|
||||
"smallSkipDuration": "Korte skip duur",
|
||||
"largeSkipDuration": "Lange skip duur",
|
||||
"secondsUnit": "${seconds} seconden",
|
||||
"defaultSleepTimer": "Standaard slaap timer",
|
||||
"minutesUnit": "bij ${minutes} minuten",
|
||||
"rememberTrackSelections": "Onthoud track selecties per serie/film",
|
||||
"rememberTrackSelectionsDescription": "Bewaar automatisch audio- en ondertiteltaalvoorkeuren wanneer je tracks wijzigt tijdens afspelen",
|
||||
"unwatchedOnly": "Alleen ongekeken",
|
||||
"unwatchedOnlyDescription": "Alleen ongekeken afleveringen opnemen in willekeurige wachtrij",
|
||||
"shuffleOrderNavigation": "Willekeurige volgorde navigatie",
|
||||
"shuffleOrderNavigationDescription": "Volgende/vorige knoppen volgen willekeurige volgorde",
|
||||
"loopShuffleQueue": "Herhaal willekeurige wachtrij",
|
||||
"loopShuffleQueueDescription": "Start wachtrij opnieuw bij het einde",
|
||||
"videoPlayerControls": "Videospeler bediening",
|
||||
"keyboardShortcuts": "Toetsenbord sneltoetsen",
|
||||
"keyboardShortcutsDescription": "Pas toetsenbord sneltoetsen aan",
|
||||
"debugLogging": "Debug logging",
|
||||
"debugLoggingDescription": "Schakel gedetailleerde logging in voor probleemoplossing",
|
||||
"viewLogs": "Bekijk logs",
|
||||
"viewLogsDescription": "Bekijk applicatie logs",
|
||||
"clearCache": "Cache wissen",
|
||||
"clearCacheDescription": "Dit wist alle gecachte afbeeldingen en gegevens. De app kan langer duren om inhoud te laden na het wissen van de cache.",
|
||||
"clearCacheSuccess": "Cache succesvol gewist",
|
||||
"resetSettings": "Instellingen resetten",
|
||||
"resetSettingsDescription": "Dit reset alle instellingen naar hun standaard waarden. Deze actie kan niet ongedaan gemaakt worden.",
|
||||
"resetSettingsSuccess": "Instellingen succesvol gereset",
|
||||
"shortcutsReset": "Sneltoetsen gereset naar standaard",
|
||||
"about": "Over",
|
||||
"aboutDescription": "App informatie en licenties",
|
||||
"updates": "Updates",
|
||||
"updateAvailable": "Update beschikbaar",
|
||||
"checkForUpdates": "Controleer op updates",
|
||||
"validationErrorEnterNumber": "Voer een geldig nummer in",
|
||||
"validationErrorDuration": "Duur moet tussen ${min} en ${max} ${unit} zijn",
|
||||
"shortcutAlreadyAssigned": "Sneltoets al toegewezen aan ${action}",
|
||||
"shortcutUpdated": "Sneltoets bijgewerkt voor ${action}"
|
||||
},
|
||||
"search": {
|
||||
"hint": "Zoek films, series, muziek...",
|
||||
"tryDifferentTerm": "Probeer een andere zoekterm",
|
||||
"searchYourMedia": "Zoek in je media",
|
||||
"enterTitleActorOrKeyword": "Voer een titel, acteur of trefwoord in"
|
||||
},
|
||||
"hotkeys": {
|
||||
"setShortcutFor": "Stel sneltoets in voor ${actionName}",
|
||||
"clearShortcut": "Wis sneltoets"
|
||||
},
|
||||
"pinEntry": {
|
||||
"enterPin": "Voer PIN in",
|
||||
"showPin": "Toon PIN",
|
||||
"hidePin": "Verberg PIN"
|
||||
},
|
||||
"fileInfo": {
|
||||
"title": "Bestand info",
|
||||
"video": "Video",
|
||||
"audio": "Audio",
|
||||
"file": "Bestand",
|
||||
"advanced": "Geavanceerd",
|
||||
"codec": "Codec",
|
||||
"resolution": "Resolutie",
|
||||
"bitrate": "Bitrate",
|
||||
"frameRate": "Frame rate",
|
||||
"aspectRatio": "Beeldverhouding",
|
||||
"profile": "Profiel",
|
||||
"bitDepth": "Bit diepte",
|
||||
"colorSpace": "Kleurruimte",
|
||||
"colorRange": "Kleurbereik",
|
||||
"colorPrimaries": "Kleurprimaires",
|
||||
"chromaSubsampling": "Chroma subsampling",
|
||||
"channels": "Kanalen",
|
||||
"path": "Pad",
|
||||
"size": "Grootte",
|
||||
"container": "Container",
|
||||
"duration": "Duur",
|
||||
"optimizedForStreaming": "Geoptimaliseerd voor streaming",
|
||||
"has64bitOffsets": "64-bit Offsets"
|
||||
},
|
||||
"mediaMenu": {
|
||||
"markAsWatched": "Markeer als gekeken",
|
||||
"markAsUnwatched": "Markeer als ongekeken",
|
||||
"removeFromContinueWatching": "Verwijder uit Doorgaan met kijken",
|
||||
"goToSeries": "Ga naar serie",
|
||||
"goToSeason": "Ga naar seizoen",
|
||||
"shufflePlay": "Willekeurig afspelen",
|
||||
"fileInfo": "Bestand info"
|
||||
},
|
||||
"accessibility": {
|
||||
"mediaCardMovie": "${title}, film",
|
||||
"mediaCardShow": "${title}, TV-serie",
|
||||
"mediaCardEpisode": "${title}, ${episodeInfo}",
|
||||
"mediaCardSeason": "${title}, ${seasonInfo}",
|
||||
"mediaCardWatched": "bekeken",
|
||||
"mediaCardPartiallyWatched": "${percent} procent bekeken",
|
||||
"mediaCardUnwatched": "niet bekeken",
|
||||
"tapToPlay": "Tik om af te spelen"
|
||||
},
|
||||
"tooltips": {
|
||||
"shufflePlay": "Willekeurig afspelen",
|
||||
"markAsWatched": "Markeer als gekeken",
|
||||
"markAsUnwatched": "Markeer als ongekeken"
|
||||
},
|
||||
"videoControls": {
|
||||
"audioLabel": "Audio",
|
||||
"subtitlesLabel": "Ondertitels",
|
||||
"resetToZero": "Reset naar 0ms",
|
||||
"addTime": "+${amount}${unit}",
|
||||
"minusTime": "-${amount}${unit}",
|
||||
"playsLater": "${label} speelt later af",
|
||||
"playsEarlier": "${label} speelt eerder af",
|
||||
"noOffset": "Geen offset",
|
||||
"letterbox": "Letterbox",
|
||||
"fillScreen": "Vul scherm",
|
||||
"stretch": "Uitrekken",
|
||||
"lockRotation": "Vergrendel rotatie",
|
||||
"unlockRotation": "Ontgrendel rotatie",
|
||||
"sleepTimer": "Slaaptimer",
|
||||
"timerActive": "Timer actief",
|
||||
"playbackWillPauseIn": "Afspelen wordt gepauzeerd over ${duration}",
|
||||
"sleepTimerCompleted": "Slaaptimer voltooid - afspelen gepauzeerd",
|
||||
"playButton": "Afspelen",
|
||||
"pauseButton": "Pauzeren",
|
||||
"seekBackwardButton": "Terugspoelen ${seconds} seconden",
|
||||
"seekForwardButton": "Vooruitspoelen ${seconds} seconden",
|
||||
"previousButton": "Vorige aflevering",
|
||||
"nextButton": "Volgende aflevering",
|
||||
"previousChapterButton": "Vorig hoofdstuk",
|
||||
"nextChapterButton": "Volgend hoofdstuk",
|
||||
"volumeButton": "Volume",
|
||||
"muteButton": "Dempen",
|
||||
"unmuteButton": "Dempen opheffen",
|
||||
"settingsButton": "Video-instellingen",
|
||||
"audioTrackButton": "Audiosporen",
|
||||
"subtitlesButton": "Ondertitels",
|
||||
"chaptersButton": "Hoofdstukken",
|
||||
"versionsButton": "Videoversies",
|
||||
"aspectRatioButton": "Beeldverhouding",
|
||||
"fullscreenButton": "Volledig scherm activeren",
|
||||
"exitFullscreenButton": "Volledig scherm verlaten",
|
||||
"rotationLockButton": "Rotatievergrendeling",
|
||||
"timelineSlider": "Videotijdlijn",
|
||||
"volumeSlider": "Volumeniveau",
|
||||
"backButton": "Terug"
|
||||
},
|
||||
"userStatus": {
|
||||
"admin": "Beheerder",
|
||||
"restricted": "Beperkt",
|
||||
"protected": "Beschermd",
|
||||
"current": "HUIDIG"
|
||||
},
|
||||
"messages": {
|
||||
"markedAsWatched": "Gemarkeerd als gekeken",
|
||||
"markedAsUnwatched": "Gemarkeerd als ongekeken",
|
||||
"removedFromContinueWatching": "Verwijderd uit Doorgaan met kijken",
|
||||
"errorLoading": "Fout: ${error}",
|
||||
"fileInfoNotAvailable": "Bestand informatie niet beschikbaar",
|
||||
"errorLoadingFileInfo": "Fout bij laden bestand info: ${error}",
|
||||
"errorLoadingSeries": "Fout bij laden serie",
|
||||
"errorLoadingSeason": "Fout bij laden seizoen",
|
||||
"musicNotSupported": "Muziek afspelen wordt nog niet ondersteund",
|
||||
"logsCleared": "Logs gewist",
|
||||
"logsCopied": "Logs gekopieerd naar klembord",
|
||||
"noLogsAvailable": "Geen logs beschikbaar",
|
||||
"libraryScanning": "Scannen \"${title}\"...",
|
||||
"libraryScanStarted": "Bibliotheek scan gestart voor \"${title}\"",
|
||||
"libraryScanFailed": "Kon bibliotheek niet scannen: ${error}",
|
||||
"metadataRefreshing": "Metadata vernieuwen voor \"${title}\"...",
|
||||
"metadataRefreshStarted": "Metadata vernieuwen gestart voor \"${title}\"",
|
||||
"metadataRefreshFailed": "Kon metadata niet vernieuwen: ${error}",
|
||||
"noPlexToken": "Geen Plex token gevonden. Log opnieuw in.",
|
||||
"logoutConfirm": "Weet je zeker dat je wilt uitloggen?",
|
||||
"noSeasonsFound": "Geen seizoenen gevonden",
|
||||
"noEpisodesFound": "Geen afleveringen gevonden in eerste seizoen",
|
||||
"noEpisodesFoundGeneral": "Geen afleveringen gevonden",
|
||||
"noResultsFound": "Geen resultaten gevonden",
|
||||
"sleepTimerSet": "Slaap timer ingesteld voor ${label}",
|
||||
"failedToSwitchProfile": "Kon niet wisselen naar ${displayName}",
|
||||
"noItemsAvailable": "Geen items beschikbaar",
|
||||
"failedToCreatePlayQueue": "Kan afspeelwachtrij niet maken",
|
||||
"failedToCreatePlayQueueNoItems": "Kan afspeelwachtrij niet maken - geen items",
|
||||
"failedPlayback": "Afspelen van ${action} mislukt: ${error}"
|
||||
},
|
||||
"profile": {
|
||||
"noUsersAvailable": "Geen gebruikers beschikbaar"
|
||||
},
|
||||
"subtitlingStyling": {
|
||||
"stylingOptions": "Opmaak opties",
|
||||
"fontSize": "Lettergrootte",
|
||||
"textColor": "Tekstkleur",
|
||||
"borderSize": "Rand grootte",
|
||||
"borderColor": "Randkleur",
|
||||
"backgroundOpacity": "Achtergrond transparantie",
|
||||
"backgroundColor": "Achtergrondkleur"
|
||||
},
|
||||
"dialog": {
|
||||
"confirmAction": "Bevestig actie",
|
||||
"areYouSure": "Weet je zeker dat je deze actie wilt uitvoeren?",
|
||||
"cancel": "Annuleren",
|
||||
"playNow": "Nu afspelen"
|
||||
},
|
||||
"discover": {
|
||||
"title": "Ontdekken",
|
||||
"switchProfile": "Wissel van profiel",
|
||||
"switchServer": "Wissel van server",
|
||||
"logout": "Uitloggen",
|
||||
"noContentAvailable": "Geen inhoud beschikbaar",
|
||||
"addMediaToLibraries": "Voeg wat media toe aan je bibliotheken",
|
||||
"continueWatching": "Verder kijken",
|
||||
"recentlyAdded": "Recent toegevoegd",
|
||||
"play": "Afspelen",
|
||||
"resume": "Hervatten",
|
||||
"playEpisode": "Speel S${season}, E${episode}",
|
||||
"resumeEpisode": "Hervat S${season}, E${episode}",
|
||||
"pause": "Pauzeren",
|
||||
"overview": "Overzicht",
|
||||
"cast": "Acteurs",
|
||||
"seasons": "Seizoenen",
|
||||
"studio": "Studio",
|
||||
"rating": "Leeftijd",
|
||||
"watched": "Bekeken",
|
||||
"episodeCount": "${count} afleveringen",
|
||||
"watchedProgress": "${watched}/${total} gekeken",
|
||||
"movie": "Film",
|
||||
"tvShow": "TV Serie",
|
||||
"minutesLeft": "${minutes} min over"
|
||||
},
|
||||
"errors": {
|
||||
"searchFailed": "Zoeken mislukt: ${error}",
|
||||
"connectionTimeout": "Verbinding time-out tijdens laden ${context}",
|
||||
"connectionFailed": "Kan geen verbinding maken met Plex server",
|
||||
"failedToLoad": "Kon ${context} niet laden: ${error}",
|
||||
"noClientAvailable": "Geen client beschikbaar",
|
||||
"authenticationFailed": "Authenticatie mislukt: ${error}",
|
||||
"couldNotLaunchUrl": "Kon auth URL niet openen",
|
||||
"pleaseEnterToken": "Voer een token in",
|
||||
"invalidToken": "Ongeldig token",
|
||||
"failedToVerifyToken": "Kon token niet verifiëren: ${error}",
|
||||
"failedToSwitchProfile": "Kon niet wisselen naar ${displayName}",
|
||||
"connectionFailedGeneric": "Verbinding mislukt"
|
||||
},
|
||||
"libraries": {
|
||||
"title": "Bibliotheken",
|
||||
"scanLibraryFiles": "Scan bibliotheek bestanden",
|
||||
"scanLibrary": "Scan bibliotheek",
|
||||
"analyze": "Analyseren",
|
||||
"analyzeLibrary": "Analyseer bibliotheek",
|
||||
"refreshMetadata": "Vernieuw metadata",
|
||||
"emptyTrash": "Prullenbak legen",
|
||||
"emptyingTrash": "Prullenbak legen voor \"${title}\"...",
|
||||
"trashEmptied": "Prullenbak geleegd voor \"${title}\"",
|
||||
"failedToEmptyTrash": "Kon prullenbak niet legen: ${error}",
|
||||
"analyzing": "Analyseren \"${title}\"...",
|
||||
"analysisStarted": "Analyse gestart voor \"${title}\"",
|
||||
"failedToAnalyze": "Kon bibliotheek niet analyseren: ${error}",
|
||||
"noLibrariesFound": "Geen bibliotheken gevonden",
|
||||
"thisLibraryIsEmpty": "Deze bibliotheek is leeg",
|
||||
"all": "Alles",
|
||||
"clearAll": "Alles wissen",
|
||||
"scanLibraryConfirm": "Weet je zeker dat je \"${title}\" wilt scannen?",
|
||||
"analyzeLibraryConfirm": "Weet je zeker dat je \"${title}\" wilt analyseren?",
|
||||
"refreshMetadataConfirm": "Weet je zeker dat je metadata wilt vernieuwen voor \"${title}\"?",
|
||||
"emptyTrashConfirm": "Weet je zeker dat je de prullenbak wilt legen voor \"${title}\"?",
|
||||
"manageLibraries": "Beheer bibliotheken",
|
||||
"sort": "Sorteren",
|
||||
"sortBy": "Sorteer op",
|
||||
"filters": "Filters",
|
||||
"loadingLibraryWithCount": "Bibliotheek laden... (${count} items geladen)",
|
||||
"confirmActionMessage": "Weet je zeker dat je deze actie wilt uitvoeren?",
|
||||
"showLibrary": "Toon bibliotheek",
|
||||
"hideLibrary": "Verberg bibliotheek",
|
||||
"libraryOptions": "Bibliotheek opties",
|
||||
"content": "bibliotheekinhoud",
|
||||
"selectLibrary": "Bibliotheek kiezen",
|
||||
"filtersWithCount": "Filters (${count})",
|
||||
"noRecommendations": "Geen aanbevelingen beschikbaar",
|
||||
"noCollections": "Geen collecties in deze bibliotheek",
|
||||
"noFoldersFound": "Geen mappen gevonden",
|
||||
"folders": "mappen",
|
||||
"tabs": {
|
||||
"recommended": "Aanbevolen",
|
||||
"browse": "Bladeren",
|
||||
"collections": "Collecties",
|
||||
"playlists": "Afspeellijsten"
|
||||
},
|
||||
"groupings": {
|
||||
"all": "Alles",
|
||||
"movies": "Films",
|
||||
"shows": "Series",
|
||||
"seasons": "Seizoenen",
|
||||
"episodes": "Afleveringen",
|
||||
"folders": "Mappen"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"title": "Over",
|
||||
"openSourceLicenses": "Open Source licenties",
|
||||
"versionLabel": "Versie ${version}",
|
||||
"appDescription": "Een mooie Plex client voor Flutter",
|
||||
"viewLicensesDescription": "Bekijk licenties van third-party bibliotheken"
|
||||
},
|
||||
"serverSelection": {
|
||||
"connectingToServer": "Verbinden met server...",
|
||||
"connectingToAllServers": "Verbinden met alle servers...",
|
||||
"connectToAllServers": "Verbind met alle servers",
|
||||
"allServerConnectionsFailed": "Kon niet verbinden met servers. Controleer je netwerk en probeer opnieuw.",
|
||||
"foundServers": "${count} server(s) gevonden",
|
||||
"orSelectIndividually": "Of selecteer een server individueel:",
|
||||
"serverDebugCopied": "Server debug gegevens gekopieerd naar klembord",
|
||||
"copyDebugData": "Kopieer debug gegevens",
|
||||
"noServersFound": "Geen servers gevonden",
|
||||
"malformedServerData": "${count} server(s) gevonden met verkeerde data. Geen geldige servers beschikbaar.",
|
||||
"incompleteServerInfo": "Sommige servers hebben incomplete informatie en zijn overgeslagen. Controleer je Plex.tv account.",
|
||||
"incompleteConnectionInfo": "Server verbinding informatie is incompleet. Probeer opnieuw.",
|
||||
"malformedServerInfo": "Server informatie is verkeerd geformatteerd: ${message}",
|
||||
"networkConnectionFailed": "Netwerkverbinding mislukt. Controleer je internetverbinding en probeer opnieuw.",
|
||||
"authenticationFailed": "Authenticatie mislukt. Log opnieuw in.",
|
||||
"plexServiceUnavailable": "Plex service niet beschikbaar. Probeer later opnieuw.",
|
||||
"failedToLoadServers": "Kon servers niet laden: ${error}"
|
||||
},
|
||||
"hubDetail": {
|
||||
"title": "Titel",
|
||||
"releaseYear": "Uitgavejaar",
|
||||
"dateAdded": "Datum toegevoegd",
|
||||
"rating": "Beoordeling",
|
||||
"noItemsFound": "Geen items gevonden"
|
||||
},
|
||||
"logs": {
|
||||
"title": "Logbestanden",
|
||||
"clearLogs": "Wis logs",
|
||||
"copyLogs": "Kopieer logs",
|
||||
"exportLogs": "Exporteer logs",
|
||||
"noLogsToShow": "Geen logs om te tonen",
|
||||
"error": "Fout:",
|
||||
"stackTrace": "Stacktracering:"
|
||||
},
|
||||
"licenses": {
|
||||
"relatedPackages": "Gerelateerde pakketten",
|
||||
"license": "Licentie",
|
||||
"licenseNumber": "Licentie ${number}",
|
||||
"licensesCount": "${count} licenties"
|
||||
},
|
||||
"navigation": {
|
||||
"home": "Thuis",
|
||||
"search": "Zoeken",
|
||||
"libraries": "Bibliotheken",
|
||||
"settings": "Instellingen"
|
||||
},
|
||||
"playlists": {
|
||||
"title": "Afspeellijsten",
|
||||
"noPlaylists": "Geen afspeellijsten gevonden",
|
||||
"create": "Afspeellijst maken",
|
||||
"newPlaylist": "Nieuwe afspeellijst",
|
||||
"playlistName": "Naam afspeellijst",
|
||||
"enterPlaylistName": "Voer naam afspeellijst in",
|
||||
"edit": "Afspeellijst bewerken",
|
||||
"delete": "Afspeellijst verwijderen",
|
||||
"addTo": "Toevoegen aan afspeellijst",
|
||||
"addItems": "Items toevoegen",
|
||||
"removeItem": "Verwijderen uit afspeellijst",
|
||||
"clearPlaylist": "Afspeellijst wissen",
|
||||
"playAll": "Alles afspelen",
|
||||
"shuffle": "Willekeurig",
|
||||
"smartPlaylist": "Slimme afspeellijst",
|
||||
"regularPlaylist": "Normale afspeellijst",
|
||||
"itemCount": "${count} items",
|
||||
"oneItem": "1 item",
|
||||
"emptyPlaylist": "Deze afspeellijst is leeg",
|
||||
"deleteConfirm": "Afspeellijst verwijderen?",
|
||||
"deleteMessage": "Weet je zeker dat je \"${name}\" wilt verwijderen?",
|
||||
"created": "Afspeellijst gemaakt",
|
||||
"updated": "Afspeellijst bijgewerkt",
|
||||
"deleted": "Afspeellijst verwijderd",
|
||||
"itemAdded": "Toegevoegd aan afspeellijst",
|
||||
"itemRemoved": "Verwijderd uit afspeellijst",
|
||||
"selectPlaylist": "Selecteer afspeellijst",
|
||||
"createNewPlaylist": "Nieuwe afspeellijst maken",
|
||||
"errorCreating": "Fout bij maken afspeellijst",
|
||||
"errorDeleting": "Fout bij verwijderen afspeellijst",
|
||||
"errorLoading": "Fout bij laden afspeellijsten",
|
||||
"errorAdding": "Fout bij toevoegen aan afspeellijst",
|
||||
"errorReordering": "Fout bij herschikken van afspeellijstitem",
|
||||
"errorRemoving": "Fout bij verwijderen uit afspeellijst",
|
||||
"playlist": "Afspeellijst"
|
||||
},
|
||||
"collections": {
|
||||
"title": "Collecties",
|
||||
"collection": "Collectie",
|
||||
"empty": "Collectie is leeg",
|
||||
"noItems": "Geen items in deze collectie",
|
||||
"unknownLibrarySection": "Kan niet verwijderen: onbekende bibliotheeksectie",
|
||||
"deleteCollection": "Collectie verwijderen",
|
||||
"deleteConfirm": "Weet je zeker dat je \"${title}\" wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
|
||||
"deleted": "Collectie verwijderd",
|
||||
"deleteFailed": "Collectie verwijderen mislukt",
|
||||
"deleteFailedWithError": "Collectie verwijderen mislukt: ${error}",
|
||||
"failedToLoadItems": "Collectie-items laden mislukt: ${error}",
|
||||
"addTo": "Toevoegen aan collectie",
|
||||
"selectCollection": "Selecteer collectie",
|
||||
"createNewCollection": "Nieuwe collectie maken",
|
||||
"collectionName": "Collectienaam",
|
||||
"enterCollectionName": "Voer collectienaam in",
|
||||
"addedToCollection": "Toegevoegd aan collectie",
|
||||
"errorAddingToCollection": "Fout bij toevoegen aan collectie",
|
||||
"created": "Collectie gemaakt",
|
||||
"removeFromCollection": "Verwijderen uit collectie",
|
||||
"removeFromCollectionConfirm": "\"${title}\" uit deze collectie verwijderen?",
|
||||
"removedFromCollection": "Uit collectie verwijderd",
|
||||
"removeFromCollectionFailed": "Verwijderen uit collectie mislukt",
|
||||
"removeFromCollectionError": "Fout bij verwijderen uit collectie: ${error}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
{
|
||||
"app": {
|
||||
"title": "Plezy",
|
||||
"loading": "Laddar..."
|
||||
},
|
||||
"auth": {
|
||||
"signInWithPlex": "Logga in med Plex",
|
||||
"showQRCode": "Visa QR-kod",
|
||||
"cancel": "Avbryt",
|
||||
"authenticate": "Autentisera",
|
||||
"retry": "Försök igen",
|
||||
"debugEnterToken": "Debug: Ange Plex-token",
|
||||
"plexTokenLabel": "Plex-autentiseringstoken",
|
||||
"plexTokenHint": "Ange din Plex.tv-token",
|
||||
"authenticationTimeout": "Autentisering tog för lång tid. Försök igen.",
|
||||
"scanQRCodeInstruction": "Skanna denna QR-kod med en enhet inloggad på Plex för att autentisera.",
|
||||
"waitingForAuth": "Väntar på autentisering...\nVänligen slutför inloggning i din webbläsare."
|
||||
},
|
||||
"common": {
|
||||
"cancel": "Avbryt",
|
||||
"save": "Spara",
|
||||
"close": "Stäng",
|
||||
"clear": "Rensa",
|
||||
"reset": "Återställ",
|
||||
"later": "Senare",
|
||||
"submit": "Skicka",
|
||||
"confirm": "Bekräfta",
|
||||
"retry": "Försök igen",
|
||||
"playNow": "Spela nu",
|
||||
"logout": "Logga ut",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"owned": "Egen",
|
||||
"shared": "Delad",
|
||||
"current": "NUVARANDE",
|
||||
"unknown": "Okänd",
|
||||
"refresh": "Uppdatera",
|
||||
"yes": "Ja",
|
||||
"no": "Nej",
|
||||
"server": "Server",
|
||||
"delete": "Ta bort",
|
||||
"shuffle": "Blanda",
|
||||
"addTo": "Lägg till i..."
|
||||
},
|
||||
"screens": {
|
||||
"licenses": "Licenser",
|
||||
"selectServer": "Välj server",
|
||||
"switchProfile": "Byt profil",
|
||||
"subtitleStyling": "Undertext-styling",
|
||||
"search": "Sök",
|
||||
"logs": "Loggar"
|
||||
},
|
||||
"update": {
|
||||
"available": "Uppdatering tillgänglig",
|
||||
"versionAvailable": "Version ${version} är tillgänglig",
|
||||
"currentVersion": "Nuvarande: ${version}",
|
||||
"skipVersion": "Hoppa över denna version",
|
||||
"viewRelease": "Visa release",
|
||||
"latestVersion": "Du har den senaste versionen",
|
||||
"checkFailed": "Misslyckades att kontrollera uppdateringar"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Inställningar",
|
||||
"language": "Språk",
|
||||
"theme": "Tema",
|
||||
"appearance": "Utseende",
|
||||
"videoPlayback": "Videouppspelning",
|
||||
"shufflePlay": "Blanda uppspelning",
|
||||
"advanced": "Avancerat",
|
||||
"useSeasonPostersDescription": "Visa säsongsaffisch istället för serieaffisch för avsnitt",
|
||||
"showHeroSectionDescription": "Visa utvalda innehållskarusell på startsidan",
|
||||
"secondsLabel": "Sekunder",
|
||||
"minutesLabel": "Minuter",
|
||||
"secondsShort": "s",
|
||||
"minutesShort": "m",
|
||||
"durationHint": "Ange tid (${min}-${max})",
|
||||
"systemTheme": "System",
|
||||
"systemThemeDescription": "Följ systeminställningar",
|
||||
"lightTheme": "Ljust",
|
||||
"darkTheme": "Mörkt",
|
||||
"libraryDensity": "Biblioteksdensitet",
|
||||
"compact": "Kompakt",
|
||||
"compactDescription": "Mindre kort, fler objekt synliga",
|
||||
"normal": "Normal",
|
||||
"normalDescription": "Standardstorlek",
|
||||
"comfortable": "Bekväm",
|
||||
"comfortableDescription": "Större kort, färre objekt synliga",
|
||||
"viewMode": "Visningsläge",
|
||||
"gridView": "Rutnät",
|
||||
"gridViewDescription": "Visa objekt i rutnätslayout",
|
||||
"listView": "Lista",
|
||||
"listViewDescription": "Visa objekt i listlayout",
|
||||
"useSeasonPosters": "Använd säsongsaffischer",
|
||||
"showHeroSection": "Visa hjältesektion",
|
||||
"hardwareDecoding": "Hårdvaruavkodning",
|
||||
"hardwareDecodingDescription": "Använd hårdvaruacceleration när tillgängligt",
|
||||
"bufferSize": "Bufferstorlek",
|
||||
"bufferSizeMB": "${size}MB",
|
||||
"subtitleStyling": "Undertext-styling",
|
||||
"subtitleStylingDescription": "Anpassa undertextutseende",
|
||||
"smallSkipDuration": "Kort hoppvaraktighet",
|
||||
"largeSkipDuration": "Lång hoppvaraktighet",
|
||||
"secondsUnit": "${seconds} sekunder",
|
||||
"defaultSleepTimer": "Standard sovtimer",
|
||||
"minutesUnit": "${minutes} minuter",
|
||||
"rememberTrackSelections": "Kom ihåg spårval per serie/film",
|
||||
"rememberTrackSelectionsDescription": "Spara automatiskt ljud- och undertextspråkpreferenser när du ändrar spår under uppspelning",
|
||||
"unwatchedOnly": "Endast osedda",
|
||||
"unwatchedOnlyDescription": "Inkludera endast osedda avsnitt i blandningskön",
|
||||
"shuffleOrderNavigation": "Blandningsordning-navigation",
|
||||
"shuffleOrderNavigationDescription": "Nästa/föregående knappar följer blandad ordning",
|
||||
"loopShuffleQueue": "Loopa blandningskö",
|
||||
"loopShuffleQueueDescription": "Starta om kö när slutet nås",
|
||||
"videoPlayerControls": "Videospelar-kontroller",
|
||||
"keyboardShortcuts": "Tangentbordsgenvägar",
|
||||
"keyboardShortcutsDescription": "Anpassa tangentbordsgenvägar",
|
||||
"debugLogging": "Felsökningsloggning",
|
||||
"debugLoggingDescription": "Aktivera detaljerad loggning för felsökning",
|
||||
"viewLogs": "Visa loggar",
|
||||
"viewLogsDescription": "Visa applikationsloggar",
|
||||
"clearCache": "Rensa cache",
|
||||
"clearCacheDescription": "Detta rensar alla cachade bilder och data. Appen kan ta längre tid att ladda innehåll efter cache-rensning.",
|
||||
"clearCacheSuccess": "Cache rensad framgångsrikt",
|
||||
"resetSettings": "Återställ inställningar",
|
||||
"resetSettingsDescription": "Detta återställer alla inställningar till standardvärden. Denna åtgärd kan inte ångras.",
|
||||
"resetSettingsSuccess": "Inställningar återställda framgångsrikt",
|
||||
"shortcutsReset": "Genvägar återställda till standard",
|
||||
"about": "Om",
|
||||
"aboutDescription": "Appinformation och licenser",
|
||||
"updates": "Uppdateringar",
|
||||
"updateAvailable": "Uppdatering tillgänglig",
|
||||
"checkForUpdates": "Kontrollera uppdateringar",
|
||||
"validationErrorEnterNumber": "Vänligen ange ett giltigt nummer",
|
||||
"validationErrorDuration": "Tiden måste vara mellan ${min} och ${max} ${unit}",
|
||||
"shortcutAlreadyAssigned": "Genväg redan tilldelad ${action}",
|
||||
"shortcutUpdated": "Genväg uppdaterad för ${action}"
|
||||
},
|
||||
"search": {
|
||||
"hint": "Sök filmer, serier, musik...",
|
||||
"tryDifferentTerm": "Prova en annan sökterm",
|
||||
"searchYourMedia": "Sök i dina media",
|
||||
"enterTitleActorOrKeyword": "Ange en titel, skådespelare eller nyckelord"
|
||||
},
|
||||
"hotkeys": {
|
||||
"setShortcutFor": "Sätt genväg för ${actionName}",
|
||||
"clearShortcut": "Rensa genväg"
|
||||
},
|
||||
"pinEntry": {
|
||||
"enterPin": "Ange PIN",
|
||||
"showPin": "Visa PIN",
|
||||
"hidePin": "Dölj PIN"
|
||||
},
|
||||
"fileInfo": {
|
||||
"title": "Filinformation",
|
||||
"video": "Video",
|
||||
"audio": "Ljud",
|
||||
"file": "Fil",
|
||||
"advanced": "Avancerat",
|
||||
"codec": "Kodek",
|
||||
"resolution": "Upplösning",
|
||||
"bitrate": "Bithastighet",
|
||||
"frameRate": "Bildfrekvens",
|
||||
"aspectRatio": "Bildförhållande",
|
||||
"profile": "Profil",
|
||||
"bitDepth": "Bitdjup",
|
||||
"colorSpace": "Färgrymd",
|
||||
"colorRange": "Färgområde",
|
||||
"colorPrimaries": "Färggrunder",
|
||||
"chromaSubsampling": "Kroma-undersampling",
|
||||
"channels": "Kanaler",
|
||||
"path": "Sökväg",
|
||||
"size": "Storlek",
|
||||
"container": "Container",
|
||||
"duration": "Varaktighet",
|
||||
"optimizedForStreaming": "Optimerad för streaming",
|
||||
"has64bitOffsets": "64-bit offset"
|
||||
},
|
||||
"mediaMenu": {
|
||||
"markAsWatched": "Markera som sedd",
|
||||
"markAsUnwatched": "Markera som osedd",
|
||||
"removeFromContinueWatching": "Ta bort från Fortsätt titta",
|
||||
"goToSeries": "Gå till serie",
|
||||
"goToSeason": "Gå till säsong",
|
||||
"shufflePlay": "Blanda uppspelning",
|
||||
"fileInfo": "Filinformation"
|
||||
},
|
||||
"accessibility": {
|
||||
"mediaCardMovie": "${title}, film",
|
||||
"mediaCardShow": "${title}, TV-serie",
|
||||
"mediaCardEpisode": "${title}, ${episodeInfo}",
|
||||
"mediaCardSeason": "${title}, ${seasonInfo}",
|
||||
"mediaCardWatched": "sedd",
|
||||
"mediaCardPartiallyWatched": "${percent} procent sedd",
|
||||
"mediaCardUnwatched": "osedd",
|
||||
"tapToPlay": "Tryck för att spela"
|
||||
},
|
||||
"tooltips": {
|
||||
"shufflePlay": "Blanda uppspelning",
|
||||
"markAsWatched": "Markera som sedd",
|
||||
"markAsUnwatched": "Markera som osedd"
|
||||
},
|
||||
"videoControls": {
|
||||
"audioLabel": "Ljud",
|
||||
"subtitlesLabel": "Undertexter",
|
||||
"resetToZero": "Återställ till 0ms",
|
||||
"addTime": "+${amount}${unit}",
|
||||
"minusTime": "-${amount}${unit}",
|
||||
"playsLater": "${label} spelas senare",
|
||||
"playsEarlier": "${label} spelas tidigare",
|
||||
"noOffset": "Ingen offset",
|
||||
"letterbox": "Letterbox",
|
||||
"fillScreen": "Fyll skärm",
|
||||
"stretch": "Sträck",
|
||||
"lockRotation": "Lås rotation",
|
||||
"unlockRotation": "Lås upp rotation",
|
||||
"sleepTimer": "Sovtimer",
|
||||
"timerActive": "Timer aktiv",
|
||||
"playbackWillPauseIn": "Uppspelningen pausas om ${duration}",
|
||||
"sleepTimerCompleted": "Sovtimer slutförd - uppspelning pausad",
|
||||
"playButton": "Spela",
|
||||
"pauseButton": "Pausa",
|
||||
"seekBackwardButton": "Spola bakåt ${seconds} sekunder",
|
||||
"seekForwardButton": "Spola framåt ${seconds} sekunder",
|
||||
"previousButton": "Föregående avsnitt",
|
||||
"nextButton": "Nästa avsnitt",
|
||||
"previousChapterButton": "Föregående kapitel",
|
||||
"nextChapterButton": "Nästa kapitel",
|
||||
"volumeButton": "Volym",
|
||||
"muteButton": "Tysta",
|
||||
"unmuteButton": "Slå på ljud",
|
||||
"settingsButton": "Videoinställningar",
|
||||
"audioTrackButton": "Ljudspår",
|
||||
"subtitlesButton": "Undertexter",
|
||||
"chaptersButton": "Kapitel",
|
||||
"versionsButton": "Videoversioner",
|
||||
"aspectRatioButton": "Bildförhållande",
|
||||
"fullscreenButton": "Aktivera helskärm",
|
||||
"exitFullscreenButton": "Avsluta helskärm",
|
||||
"rotationLockButton": "Rotationslås",
|
||||
"timelineSlider": "Videotidslinje",
|
||||
"volumeSlider": "Volymnivå",
|
||||
"backButton": "Tillbaka"
|
||||
},
|
||||
"userStatus": {
|
||||
"admin": "Admin",
|
||||
"restricted": "Begränsad",
|
||||
"protected": "Skyddad",
|
||||
"current": "NUVARANDE"
|
||||
},
|
||||
"messages": {
|
||||
"markedAsWatched": "Markerad som sedd",
|
||||
"markedAsUnwatched": "Markerad som osedd",
|
||||
"removedFromContinueWatching": "Borttagen från Fortsätt titta",
|
||||
"errorLoading": "Fel: ${error}",
|
||||
"fileInfoNotAvailable": "Filinformation inte tillgänglig",
|
||||
"errorLoadingFileInfo": "Fel vid laddning av filinformation: ${error}",
|
||||
"errorLoadingSeries": "Fel vid laddning av serie",
|
||||
"errorLoadingSeason": "Fel vid laddning av säsong",
|
||||
"musicNotSupported": "Musikuppspelning stöds inte ännu",
|
||||
"logsCleared": "Loggar rensade",
|
||||
"logsCopied": "Loggar kopierade till urklipp",
|
||||
"noLogsAvailable": "Inga loggar tillgängliga",
|
||||
"libraryScanning": "Skannar \"${title}\"...",
|
||||
"libraryScanStarted": "Biblioteksskanning startad för \"${title}\"",
|
||||
"libraryScanFailed": "Misslyckades att skanna bibliotek: ${error}",
|
||||
"metadataRefreshing": "Uppdaterar metadata för \"${title}\"...",
|
||||
"metadataRefreshStarted": "Metadata-uppdatering startad för \"${title}\"",
|
||||
"metadataRefreshFailed": "Misslyckades att uppdatera metadata: ${error}",
|
||||
"noPlexToken": "Ingen Plex-token hittad. Vänligen logga in igen.",
|
||||
"logoutConfirm": "Är du säker på att du vill logga ut?",
|
||||
"noSeasonsFound": "Inga säsonger hittades",
|
||||
"noEpisodesFound": "Inga avsnitt hittades i första säsongen",
|
||||
"noEpisodesFoundGeneral": "Inga avsnitt hittades",
|
||||
"noResultsFound": "Inga resultat hittades",
|
||||
"sleepTimerSet": "Sovtimer inställd för ${label}",
|
||||
"failedToSwitchProfile": "Misslyckades att byta till ${displayName}",
|
||||
"noItemsAvailable": "Inga objekt tillgängliga",
|
||||
"failedToCreatePlayQueue": "Det gick inte att skapa uppspelningskö",
|
||||
"failedToCreatePlayQueueNoItems": "Det gick inte att skapa uppspelningskö – inga objekt",
|
||||
"failedPlayback": "Kunde inte ${action}: ${error}"
|
||||
},
|
||||
"profile": {
|
||||
"noUsersAvailable": "Inga användare tillgängliga"
|
||||
},
|
||||
"subtitlingStyling": {
|
||||
"stylingOptions": "Stilalternativ",
|
||||
"fontSize": "Teckenstorlek",
|
||||
"textColor": "Textfärg",
|
||||
"borderSize": "Kantstorlek",
|
||||
"borderColor": "Kantfärg",
|
||||
"backgroundOpacity": "Bakgrundsopacitet",
|
||||
"backgroundColor": "Bakgrundsfärg"
|
||||
},
|
||||
"dialog": {
|
||||
"confirmAction": "Bekräfta åtgärd",
|
||||
"areYouSure": "Är du säker på att du vill utföra denna åtgärd?",
|
||||
"cancel": "Avbryt",
|
||||
"playNow": "Spela nu"
|
||||
},
|
||||
"discover": {
|
||||
"title": "Upptäck",
|
||||
"switchProfile": "Byt profil",
|
||||
"switchServer": "Byt server",
|
||||
"logout": "Logga ut",
|
||||
"noContentAvailable": "Inget innehåll tillgängligt",
|
||||
"addMediaToLibraries": "Lägg till media till dina bibliotek",
|
||||
"continueWatching": "Fortsätt titta",
|
||||
"recentlyAdded": "Nyligen tillagda",
|
||||
"play": "Spela",
|
||||
"resume": "Återuppta",
|
||||
"playEpisode": "Spela S${season}, E${episode}",
|
||||
"resumeEpisode": "Återuppta S${season}, E${episode}",
|
||||
"pause": "Pausa",
|
||||
"overview": "Översikt",
|
||||
"cast": "Rollbesättning",
|
||||
"seasons": "Säsonger",
|
||||
"studio": "Studio",
|
||||
"rating": "Åldersgräns",
|
||||
"watched": "Tittad",
|
||||
"episodeCount": "${count} avsnitt",
|
||||
"watchedProgress": "${watched}/${total} sedda",
|
||||
"movie": "Film",
|
||||
"tvShow": "TV-serie",
|
||||
"minutesLeft": "${minutes} min kvar"
|
||||
},
|
||||
"errors": {
|
||||
"searchFailed": "Sökning misslyckades: ${error}",
|
||||
"connectionTimeout": "Anslutnings-timeout vid laddning ${context}",
|
||||
"connectionFailed": "Kan inte ansluta till Plex-server",
|
||||
"failedToLoad": "Misslyckades att ladda ${context}: ${error}",
|
||||
"noClientAvailable": "Ingen klient tillgänglig",
|
||||
"authenticationFailed": "Autentisering misslyckades: ${error}",
|
||||
"couldNotLaunchUrl": "Kunde inte öppna autentiserings-URL",
|
||||
"pleaseEnterToken": "Vänligen ange en token",
|
||||
"invalidToken": "Ogiltig token",
|
||||
"failedToVerifyToken": "Misslyckades att verifiera token: ${error}",
|
||||
"failedToSwitchProfile": "Misslyckades att byta till ${displayName}",
|
||||
"connectionFailedGeneric": "Anslutning misslyckades"
|
||||
},
|
||||
"libraries": {
|
||||
"title": "Bibliotek",
|
||||
"scanLibraryFiles": "Skanna biblioteksfiler",
|
||||
"scanLibrary": "Skanna bibliotek",
|
||||
"analyze": "Analysera",
|
||||
"analyzeLibrary": "Analysera bibliotek",
|
||||
"refreshMetadata": "Uppdatera metadata",
|
||||
"emptyTrash": "Töm papperskorg",
|
||||
"emptyingTrash": "Tömmer papperskorg för \"${title}\"...",
|
||||
"trashEmptied": "Papperskorg tömd för \"${title}\"",
|
||||
"failedToEmptyTrash": "Misslyckades att tömma papperskorg: ${error}",
|
||||
"analyzing": "Analyserar \"${title}\"...",
|
||||
"analysisStarted": "Analys startad för \"${title}\"",
|
||||
"failedToAnalyze": "Misslyckades att analysera bibliotek: ${error}",
|
||||
"noLibrariesFound": "Inga bibliotek hittades",
|
||||
"thisLibraryIsEmpty": "Detta bibliotek är tomt",
|
||||
"all": "Alla",
|
||||
"clearAll": "Rensa alla",
|
||||
"scanLibraryConfirm": "Är du säker på att du vill skanna \"${title}\"?",
|
||||
"analyzeLibraryConfirm": "Är du säker på att du vill analysera \"${title}\"?",
|
||||
"refreshMetadataConfirm": "Är du säker på att du vill uppdatera metadata för \"${title}\"?",
|
||||
"emptyTrashConfirm": "Är du säker på att du vill tömma papperskorgen för \"${title}\"?",
|
||||
"manageLibraries": "Hantera bibliotek",
|
||||
"sort": "Sortera",
|
||||
"sortBy": "Sortera efter",
|
||||
"filters": "Filter",
|
||||
"loadingLibraryWithCount": "Laddar bibliotek... (${count} objekt laddade)",
|
||||
"confirmActionMessage": "Är du säker på att du vill utföra denna åtgärd?",
|
||||
"showLibrary": "Visa bibliotek",
|
||||
"hideLibrary": "Dölj bibliotek",
|
||||
"libraryOptions": "Biblioteksalternativ",
|
||||
"content": "bibliotekets innehåll",
|
||||
"selectLibrary": "Välj bibliotek",
|
||||
"filtersWithCount": "Filter (${count})",
|
||||
"noRecommendations": "Inga rekommendationer tillgängliga",
|
||||
"noCollections": "Inga samlingar i det här biblioteket",
|
||||
"noFoldersFound": "Inga mappar hittades",
|
||||
"folders": "mappar",
|
||||
"tabs": {
|
||||
"recommended": "Rekommenderat",
|
||||
"browse": "Bläddra",
|
||||
"collections": "Samlingar",
|
||||
"playlists": "Spellistor"
|
||||
},
|
||||
"groupings": {
|
||||
"all": "Alla",
|
||||
"movies": "Filmer",
|
||||
"shows": "Serier",
|
||||
"seasons": "Säsonger",
|
||||
"episodes": "Avsnitt",
|
||||
"folders": "Mappar"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"title": "Om",
|
||||
"openSourceLicenses": "Öppen källkod-licenser",
|
||||
"versionLabel": "Version ${version}",
|
||||
"appDescription": "En vacker Plex-klient för Flutter",
|
||||
"viewLicensesDescription": "Visa licenser för tredjepartsbibliotek"
|
||||
},
|
||||
"serverSelection": {
|
||||
"connectingToServer": "Ansluter till server...",
|
||||
"connectingToAllServers": "Ansluter till alla servrar...",
|
||||
"connectToAllServers": "Anslut till alla servrar",
|
||||
"allServerConnectionsFailed": "Misslyckades att ansluta till servrar. Kontrollera ditt nätverk och försök igen.",
|
||||
"foundServers": "Hittade ${count} server(ar)",
|
||||
"orSelectIndividually": "Eller välj en server individuellt:",
|
||||
"serverDebugCopied": "Server-felsökningsdata kopierad till urklipp",
|
||||
"copyDebugData": "Kopiera felsökningsdata",
|
||||
"noServersFound": "Inga servrar hittades",
|
||||
"malformedServerData": "Hittade ${count} server(ar) med felformaterad data. Inga giltiga servrar tillgängliga.",
|
||||
"incompleteServerInfo": "Vissa servrar har ofullständig information och hoppades över. Vänligen kontrollera ditt Plex.tv-konto.",
|
||||
"incompleteConnectionInfo": "Server-anslutningsinformation är ofullständig. Försök igen.",
|
||||
"malformedServerInfo": "Serverinformation är felformaterad: ${message}",
|
||||
"networkConnectionFailed": "Nätverksanslutning misslyckades. Kontrollera din internetanslutning och försök igen.",
|
||||
"authenticationFailed": "Autentisering misslyckades. Logga in igen.",
|
||||
"plexServiceUnavailable": "Plex-tjänst otillgänglig. Försök igen senare.",
|
||||
"failedToLoadServers": "Misslyckades att ladda servrar: ${error}"
|
||||
},
|
||||
"hubDetail": {
|
||||
"title": "Titel",
|
||||
"releaseYear": "Utgivningsår",
|
||||
"dateAdded": "Datum tillagd",
|
||||
"rating": "Betyg",
|
||||
"noItemsFound": "Inga objekt hittades"
|
||||
},
|
||||
"logs": {
|
||||
"title": "Loggar",
|
||||
"clearLogs": "Rensa loggar",
|
||||
"copyLogs": "Kopiera loggar",
|
||||
"exportLogs": "Exportera loggar",
|
||||
"noLogsToShow": "Inga loggar att visa",
|
||||
"error": "Fel:",
|
||||
"stackTrace": "Stack trace:"
|
||||
},
|
||||
"licenses": {
|
||||
"relatedPackages": "Relaterade paket",
|
||||
"license": "Licens",
|
||||
"licenseNumber": "Licens ${number}",
|
||||
"licensesCount": "${count} licenser"
|
||||
},
|
||||
"navigation": {
|
||||
"home": "Hem",
|
||||
"search": "Sök",
|
||||
"libraries": "Bibliotek",
|
||||
"settings": "Inställningar"
|
||||
},
|
||||
"playlists": {
|
||||
"title": "Spellistor",
|
||||
"noPlaylists": "Inga spellistor hittades",
|
||||
"create": "Skapa spellista",
|
||||
"newPlaylist": "Ny spellista",
|
||||
"playlistName": "Spellistans namn",
|
||||
"enterPlaylistName": "Ange spellistans namn",
|
||||
"edit": "Redigera spellista",
|
||||
"delete": "Ta bort spellista",
|
||||
"addTo": "Lägg till i spellista",
|
||||
"addItems": "Lägg till objekt",
|
||||
"removeItem": "Ta bort från spellista",
|
||||
"clearPlaylist": "Rensa spellista",
|
||||
"playAll": "Spela alla",
|
||||
"shuffle": "Blanda",
|
||||
"smartPlaylist": "Smart spellista",
|
||||
"regularPlaylist": "Vanlig spellista",
|
||||
"itemCount": "${count} objekt",
|
||||
"oneItem": "1 objekt",
|
||||
"emptyPlaylist": "Denna spellista är tom",
|
||||
"deleteConfirm": "Ta bort spellista?",
|
||||
"deleteMessage": "Är du säker på att du vill ta bort \"${name}\"?",
|
||||
"created": "Spellista skapad",
|
||||
"updated": "Spellista uppdaterad",
|
||||
"deleted": "Spellista borttagen",
|
||||
"itemAdded": "Tillagd i spellista",
|
||||
"itemRemoved": "Borttagen från spellista",
|
||||
"selectPlaylist": "Välj spellista",
|
||||
"createNewPlaylist": "Skapa ny spellista",
|
||||
"errorCreating": "Det gick inte att skapa spellista",
|
||||
"errorDeleting": "Det gick inte att ta bort spellista",
|
||||
"errorLoading": "Det gick inte att ladda spellistor",
|
||||
"errorAdding": "Det gick inte att lägga till i spellista",
|
||||
"errorReordering": "Det gick inte att omordna spellisteobjekt",
|
||||
"errorRemoving": "Det gick inte att ta bort från spellista",
|
||||
"playlist": "Spellista"
|
||||
},
|
||||
"collections": {
|
||||
"title": "Samlingar",
|
||||
"collection": "Samling",
|
||||
"empty": "Samlingen är tom",
|
||||
"noItems": "Inga objekt i den här samlingen",
|
||||
"unknownLibrarySection": "Kan inte ta bort: okänd bibliotekssektion",
|
||||
"deleteCollection": "Ta bort samling",
|
||||
"deleteConfirm": "Är du säker på att du vill ta bort \"${title}\"? Detta går inte att ångra.",
|
||||
"deleted": "Samling borttagen",
|
||||
"deleteFailed": "Det gick inte att ta bort samlingen",
|
||||
"deleteFailedWithError": "Det gick inte att ta bort samlingen: ${error}",
|
||||
"failedToLoadItems": "Det gick inte att läsa in samlingsobjekt: ${error}",
|
||||
"addTo": "Lägg till i samling",
|
||||
"selectCollection": "Välj samling",
|
||||
"createNewCollection": "Skapa ny samling",
|
||||
"collectionName": "Samlingsnamn",
|
||||
"enterCollectionName": "Ange samlingsnamn",
|
||||
"addedToCollection": "Tillagd i samling",
|
||||
"errorAddingToCollection": "Fel vid tillägg i samling",
|
||||
"created": "Samling skapad",
|
||||
"removeFromCollection": "Ta bort från samling",
|
||||
"removeFromCollectionConfirm": "Ta bort \"${title}\" från denna samling?",
|
||||
"removedFromCollection": "Borttagen från samling",
|
||||
"removeFromCollectionFailed": "Misslyckades med att ta bort från samling",
|
||||
"removeFromCollectionError": "Fel vid borttagning från samling: ${error}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
{
|
||||
"app": {
|
||||
"title": "Plezy",
|
||||
"loading": "加载中..."
|
||||
},
|
||||
"auth": {
|
||||
"signInWithPlex": "使用 Plex 登录",
|
||||
"showQRCode": "显示二维码",
|
||||
"cancel": "取消",
|
||||
"authenticate": "验证",
|
||||
"retry": "重试",
|
||||
"debugEnterToken": "调试:输入 Plex Token",
|
||||
"plexTokenLabel": "Plex 授权令牌 (Auth Token)",
|
||||
"plexTokenHint": "输入你的 Plex.tv 令牌",
|
||||
"authenticationTimeout": "验证超时。请重试。",
|
||||
"scanQRCodeInstruction": "请使用已登录 Plex 的设备扫描此二维码进行验证。",
|
||||
"waitingForAuth": "等待验证中...\n请在你的浏览器中完成登录。"
|
||||
},
|
||||
"common": {
|
||||
"cancel": "取消",
|
||||
"save": "保存",
|
||||
"close": "关闭",
|
||||
"clear": "清除",
|
||||
"reset": "重置",
|
||||
"later": "稍后",
|
||||
"submit": "提交",
|
||||
"confirm": "确认",
|
||||
"retry": "重试",
|
||||
"playNow": "立即播放",
|
||||
"logout": "登出",
|
||||
"online": "在线",
|
||||
"offline": "离线",
|
||||
"owned": "拥有",
|
||||
"shared": "共享",
|
||||
"current": "当前",
|
||||
"unknown": "未知",
|
||||
"refresh": "刷新",
|
||||
"yes": "是",
|
||||
"no": "否",
|
||||
"server": "服务器",
|
||||
"delete": "删除",
|
||||
"shuffle": "随机播放",
|
||||
"addTo": "添加到..."
|
||||
},
|
||||
"screens": {
|
||||
"licenses": "许可证",
|
||||
"selectServer": "选择服务器",
|
||||
"switchProfile": "切换配置文件",
|
||||
"subtitleStyling": "字幕样式",
|
||||
"search": "搜索",
|
||||
"logs": "日志"
|
||||
},
|
||||
"update": {
|
||||
"available": "有可用更新",
|
||||
"versionAvailable": "版本 ${version} 已发布",
|
||||
"currentVersion": "当前版本: ${version}",
|
||||
"skipVersion": "跳过此版本",
|
||||
"viewRelease": "查看发布详情",
|
||||
"latestVersion": "已安装的版本是可用的最新版本",
|
||||
"checkFailed": "无法检查更新"
|
||||
},
|
||||
"settings": {
|
||||
"title": "设置",
|
||||
"language": "语言",
|
||||
"theme": "主题",
|
||||
"appearance": "外观",
|
||||
"videoPlayback": "视频播放",
|
||||
"shufflePlay": "随机播放",
|
||||
"advanced": "高级",
|
||||
"useSeasonPostersDescription": "为剧集显示季海报而非剧集海报",
|
||||
"showHeroSectionDescription": "在主屏幕上显示精选内容轮播区",
|
||||
"secondsLabel": "秒",
|
||||
"minutesLabel": "分钟",
|
||||
"secondsShort": "s",
|
||||
"minutesShort": "m",
|
||||
"durationHint": "输入时长 (${min}-${max})",
|
||||
"systemTheme": "系统",
|
||||
"systemThemeDescription": "跟随系统设置",
|
||||
"lightTheme": "浅色",
|
||||
"darkTheme": "深色",
|
||||
"libraryDensity": "媒体库密度",
|
||||
"compact": "紧凑",
|
||||
"compactDescription": "卡片更小,显示更多项目",
|
||||
"normal": "标准",
|
||||
"normalDescription": "默认尺寸",
|
||||
"comfortable": "舒适",
|
||||
"comfortableDescription": "卡片更大,显示更少项目",
|
||||
"viewMode": "视图模式",
|
||||
"gridView": "网格视图",
|
||||
"gridViewDescription": "以网格布局显示项目",
|
||||
"listView": "列表视图",
|
||||
"listViewDescription": "以列表布局显示项目",
|
||||
"useSeasonPosters": "使用季海报",
|
||||
"showHeroSection": "显示主要精选区",
|
||||
"hardwareDecoding": "硬件解码",
|
||||
"hardwareDecodingDescription": "如果可用,使用硬件加速",
|
||||
"bufferSize": "缓冲区大小",
|
||||
"bufferSizeMB": "${size}MB",
|
||||
"subtitleStyling": "字幕样式",
|
||||
"subtitleStylingDescription": "调整字幕外观",
|
||||
"smallSkipDuration": "短跳过时长",
|
||||
"largeSkipDuration": "长跳过时长",
|
||||
"secondsUnit": "${seconds} 秒",
|
||||
"defaultSleepTimer": "默认睡眠定时器",
|
||||
"minutesUnit": "${minutes} 分钟",
|
||||
"rememberTrackSelections": "记住每个剧集/电影的音轨选择",
|
||||
"rememberTrackSelectionsDescription": "在播放过程中更改音轨时自动保存音频和字幕语言偏好",
|
||||
"unwatchedOnly": "仅未观看",
|
||||
"unwatchedOnlyDescription": "随机队列中仅包含未观看的剧集",
|
||||
"shuffleOrderNavigation": "随机顺序导航",
|
||||
"shuffleOrderNavigationDescription": "下一集/上一集按钮遵循随机播放顺序",
|
||||
"loopShuffleQueue": "循环随机队列",
|
||||
"loopShuffleQueueDescription": "在队列结束时重新开始",
|
||||
"videoPlayerControls": "视频播放器控制",
|
||||
"keyboardShortcuts": "键盘快捷键",
|
||||
"keyboardShortcutsDescription": "自定义键盘快捷键",
|
||||
"debugLogging": "调试日志",
|
||||
"debugLoggingDescription": "启用详细日志记录以便故障排除",
|
||||
"viewLogs": "查看日志",
|
||||
"viewLogsDescription": "查看应用程序日志",
|
||||
"clearCache": "清除缓存",
|
||||
"clearCacheDescription": "这将清除所有缓存的图片和数据。清除缓存后,应用程序加载内容可能会变慢。",
|
||||
"clearCacheSuccess": "缓存清除成功",
|
||||
"resetSettings": "重置设置",
|
||||
"resetSettingsDescription": "这会将所有设置重置为其默认值。此操作无法撤销。",
|
||||
"resetSettingsSuccess": "设置重置成功",
|
||||
"shortcutsReset": "快捷键已重置为默认值",
|
||||
"about": "关于",
|
||||
"aboutDescription": "应用程序信息和许可证",
|
||||
"updates": "更新",
|
||||
"updateAvailable": "有可用更新",
|
||||
"checkForUpdates": "检查更新",
|
||||
"validationErrorEnterNumber": "请输入一个有效的数字",
|
||||
"validationErrorDuration": "时长必须介于 ${min} 和 ${max} ${unit} 之间",
|
||||
"shortcutAlreadyAssigned": "快捷键已被分配给 ${action}",
|
||||
"shortcutUpdated": "快捷键已为 ${action} 更新"
|
||||
},
|
||||
"search": {
|
||||
"hint": "搜索电影、系列、音乐...",
|
||||
"tryDifferentTerm": "尝试不同的搜索词",
|
||||
"searchYourMedia": "搜索媒体",
|
||||
"enterTitleActorOrKeyword": "输入标题、演员或关键词"
|
||||
},
|
||||
"hotkeys": {
|
||||
"setShortcutFor": "为 ${actionName} 设置快捷键",
|
||||
"clearShortcut": "清除快捷键"
|
||||
},
|
||||
"pinEntry": {
|
||||
"enterPin": "输入 PIN",
|
||||
"showPin": "显示 PIN",
|
||||
"hidePin": "隐藏 PIN"
|
||||
},
|
||||
"fileInfo": {
|
||||
"title": "文件信息",
|
||||
"video": "视频",
|
||||
"audio": "音频",
|
||||
"file": "文件",
|
||||
"advanced": "高级",
|
||||
"codec": "编解码器",
|
||||
"resolution": "分辨率",
|
||||
"bitrate": "比特率",
|
||||
"frameRate": "帧率",
|
||||
"aspectRatio": "宽高比",
|
||||
"profile": "配置文件",
|
||||
"bitDepth": "位深度",
|
||||
"colorSpace": "色彩空间",
|
||||
"colorRange": "色彩范围",
|
||||
"colorPrimaries": "颜色原色",
|
||||
"chromaSubsampling": "色度子采样",
|
||||
"channels": "声道",
|
||||
"path": "路径",
|
||||
"size": "大小",
|
||||
"container": "容器",
|
||||
"duration": "时长",
|
||||
"optimizedForStreaming": "已优化用于流媒体",
|
||||
"has64bitOffsets": "64位偏移量"
|
||||
},
|
||||
"mediaMenu": {
|
||||
"markAsWatched": "标记为已观看",
|
||||
"markAsUnwatched": "标记为未观看",
|
||||
"removeFromContinueWatching": "从继续观看中移除",
|
||||
"goToSeries": "转到系列",
|
||||
"goToSeason": "转到季",
|
||||
"shufflePlay": "随机播放",
|
||||
"fileInfo": "文件信息"
|
||||
},
|
||||
"accessibility": {
|
||||
"mediaCardMovie": "${title}, 电影",
|
||||
"mediaCardShow": "${title}, 电视剧",
|
||||
"mediaCardEpisode": "${title}, ${episodeInfo}",
|
||||
"mediaCardSeason": "${title}, ${seasonInfo}",
|
||||
"mediaCardWatched": "已观看",
|
||||
"mediaCardPartiallyWatched": "已观看 ${percent} 百分比",
|
||||
"mediaCardUnwatched": "未观看",
|
||||
"tapToPlay": "点击播放"
|
||||
},
|
||||
"tooltips": {
|
||||
"shufflePlay": "随机播放",
|
||||
"markAsWatched": "标记为已观看",
|
||||
"markAsUnwatched": "标记为未观看"
|
||||
},
|
||||
"videoControls": {
|
||||
"audioLabel": "音频",
|
||||
"subtitlesLabel": "字幕",
|
||||
"resetToZero": "重置为 0ms",
|
||||
"addTime": "+${amount}${unit}",
|
||||
"minusTime": "-${amount}${unit}",
|
||||
"playsLater": "${label} 播放较晚",
|
||||
"playsEarlier": "${label} 播放较早",
|
||||
"noOffset": "无偏移",
|
||||
"letterbox": "信箱模式(Letterbox)",
|
||||
"fillScreen": "填充屏幕",
|
||||
"stretch": "拉伸",
|
||||
"lockRotation": "锁定旋转",
|
||||
"unlockRotation": "解锁旋转",
|
||||
"sleepTimer": "睡眠定时器",
|
||||
"timerActive": "定时器已激活",
|
||||
"playbackWillPauseIn": "播放将在 ${duration} 后暂停",
|
||||
"sleepTimerCompleted": "睡眠定时器已完成 - 播放已暂停",
|
||||
"playButton": "播放",
|
||||
"pauseButton": "暂停",
|
||||
"seekBackwardButton": "后退 ${seconds} 秒",
|
||||
"seekForwardButton": "前进 ${seconds} 秒",
|
||||
"previousButton": "上一集",
|
||||
"nextButton": "下一集",
|
||||
"previousChapterButton": "上一章节",
|
||||
"nextChapterButton": "下一章节",
|
||||
"volumeButton": "音量",
|
||||
"muteButton": "静音",
|
||||
"unmuteButton": "取消静音",
|
||||
"settingsButton": "视频设置",
|
||||
"audioTrackButton": "音轨",
|
||||
"subtitlesButton": "字幕",
|
||||
"chaptersButton": "章节",
|
||||
"versionsButton": "视频版本",
|
||||
"aspectRatioButton": "宽高比",
|
||||
"fullscreenButton": "进入全屏",
|
||||
"exitFullscreenButton": "退出全屏",
|
||||
"rotationLockButton": "旋转锁定",
|
||||
"timelineSlider": "视频时间轴",
|
||||
"volumeSlider": "音量调节",
|
||||
"backButton": "返回"
|
||||
},
|
||||
"userStatus": {
|
||||
"admin": "管理员",
|
||||
"restricted": "受限",
|
||||
"protected": "受保护",
|
||||
"current": "当前"
|
||||
},
|
||||
"messages": {
|
||||
"markedAsWatched": "已标记为已观看",
|
||||
"markedAsUnwatched": "已标记为未观看",
|
||||
"removedFromContinueWatching": "已从继续观看中移除",
|
||||
"errorLoading": "错误: ${error}",
|
||||
"fileInfoNotAvailable": "文件信息不可用",
|
||||
"errorLoadingFileInfo": "加载文件信息时出错: ${error}",
|
||||
"errorLoadingSeries": "加载系列时出错",
|
||||
"errorLoadingSeason": "加载季时出错",
|
||||
"musicNotSupported": "尚不支持播放音乐",
|
||||
"logsCleared": "日志已清除",
|
||||
"logsCopied": "日志已复制到剪贴板",
|
||||
"noLogsAvailable": "没有可用日志",
|
||||
"libraryScanning": "正在扫描 “${title}”...",
|
||||
"libraryScanStarted": "已开始扫描 “${title}” 媒体库",
|
||||
"libraryScanFailed": "无法扫描媒体库: ${error}",
|
||||
"metadataRefreshing": "正在刷新 “${title}” 的元数据...",
|
||||
"metadataRefreshStarted": "已开始刷新 “${title}” 的元数据",
|
||||
"metadataRefreshFailed": "无法刷新元数据: ${error}",
|
||||
"noPlexToken": "未找到 Plex 令牌。请重新登录。",
|
||||
"logoutConfirm": "你确定要登出吗?",
|
||||
"noSeasonsFound": "未找到季",
|
||||
"noEpisodesFound": "在第一季中未找到剧集",
|
||||
"noEpisodesFoundGeneral": "未找到剧集",
|
||||
"noResultsFound": "未找到结果",
|
||||
"sleepTimerSet": "睡眠定时器已设置为 ${label}",
|
||||
"failedToSwitchProfile": "无法切换到 ${displayName}",
|
||||
"noItemsAvailable": "没有可用的项目",
|
||||
"failedToCreatePlayQueue": "创建播放队列失败",
|
||||
"failedToCreatePlayQueueNoItems": "创建播放队列失败 - 没有项目",
|
||||
"failedPlayback": "无法${action}: ${error}"
|
||||
},
|
||||
"profile": {
|
||||
"noUsersAvailable": "没有可用用户"
|
||||
},
|
||||
"subtitlingStyling": {
|
||||
"stylingOptions": "样式选项",
|
||||
"fontSize": "字号",
|
||||
"textColor": "文本颜色",
|
||||
"borderSize": "边框大小",
|
||||
"borderColor": "边框颜色",
|
||||
"backgroundOpacity": "背景不透明度",
|
||||
"backgroundColor": "背景颜色"
|
||||
},
|
||||
"dialog": {
|
||||
"confirmAction": "确认操作",
|
||||
"areYouSure": "确定要执行此操作吗?",
|
||||
"cancel": "取消",
|
||||
"playNow": "立即播放"
|
||||
},
|
||||
"discover": {
|
||||
"title": "发现",
|
||||
"switchProfile": "切换配置文件",
|
||||
"switchServer": "切换服务器",
|
||||
"logout": "登出",
|
||||
"noContentAvailable": "没有可用内容",
|
||||
"addMediaToLibraries": "请向你的媒体库添加一些媒体",
|
||||
"continueWatching": "继续观看",
|
||||
"recentlyAdded": "最近添加",
|
||||
"play": "播放",
|
||||
"resume": "继续",
|
||||
"playEpisode": "播放 S${season}, E${episode}",
|
||||
"resumeEpisode": "继续 S${season}, E${episode}",
|
||||
"pause": "暂停",
|
||||
"overview": "概述",
|
||||
"cast": "演员表",
|
||||
"seasons": "季数",
|
||||
"studio": "制作公司",
|
||||
"rating": "年龄分级",
|
||||
"watched": "已观看",
|
||||
"episodeCount": "${count} 集",
|
||||
"watchedProgress": "已观看 ${watched}/${total} 集",
|
||||
"movie": "电影",
|
||||
"tvShow": "电视剧",
|
||||
"minutesLeft": "剩余 ${minutes} 分钟"
|
||||
},
|
||||
"errors": {
|
||||
"searchFailed": "搜索失败: ${error}",
|
||||
"connectionTimeout": "加载 ${context} 时连接超时",
|
||||
"connectionFailed": "无法连接到 Plex 服务器",
|
||||
"failedToLoad": "无法加载 ${context}: ${error}",
|
||||
"noClientAvailable": "没有可用客户端",
|
||||
"authenticationFailed": "验证失败: ${error}",
|
||||
"couldNotLaunchUrl": "无法打开授权 URL",
|
||||
"pleaseEnterToken": "请输入一个令牌",
|
||||
"invalidToken": "令牌无效",
|
||||
"failedToVerifyToken": "无法验证令牌: ${error}",
|
||||
"failedToSwitchProfile": "无法切换到 ${displayName}",
|
||||
"connectionFailedGeneric": "连接失败"
|
||||
},
|
||||
"libraries": {
|
||||
"title": "媒体库",
|
||||
"scanLibraryFiles": "扫描媒体库文件",
|
||||
"scanLibrary": "扫描媒体库",
|
||||
"analyze": "分析",
|
||||
"analyzeLibrary": "分析媒体库",
|
||||
"refreshMetadata": "刷新元数据",
|
||||
"emptyTrash": "清空回收站",
|
||||
"emptyingTrash": "正在清空 “${title}” 的回收站...",
|
||||
"trashEmptied": "已清空 “${title}” 的回收站",
|
||||
"failedToEmptyTrash": "无法清空回收站: ${error}",
|
||||
"analyzing": "正在分析 “${title}”...",
|
||||
"analysisStarted": "已开始分析 “${title}”",
|
||||
"failedToAnalyze": "无法分析媒体库: ${error}",
|
||||
"noLibrariesFound": "未找到媒体库",
|
||||
"thisLibraryIsEmpty": "此媒体库为空",
|
||||
"all": "全部",
|
||||
"clearAll": "全部清除",
|
||||
"scanLibraryConfirm": "确定要扫描 “${title}” 吗?",
|
||||
"analyzeLibraryConfirm": "确定要分析 “${title}” 吗?",
|
||||
"refreshMetadataConfirm": "确定要刷新 “${title}” 的元数据吗?",
|
||||
"emptyTrashConfirm": "确定要清空 “${title}” 的回收站吗?",
|
||||
"manageLibraries": "管理媒体库",
|
||||
"sort": "排序",
|
||||
"sortBy": "排序依据",
|
||||
"filters": "筛选器",
|
||||
"loadingLibraryWithCount": "正在加载媒体库... (已加载 ${count} 个项目)",
|
||||
"confirmActionMessage": "确定要执行此操作吗?",
|
||||
"showLibrary": "显示媒体库",
|
||||
"hideLibrary": "隐藏媒体库",
|
||||
"libraryOptions": "媒体库选项",
|
||||
"content": "媒体库内容",
|
||||
"selectLibrary": "选择媒体库",
|
||||
"filtersWithCount": "筛选器(${count})",
|
||||
"noRecommendations": "暂无推荐",
|
||||
"noCollections": "此媒体库中没有合集",
|
||||
"noFoldersFound": "未找到文件夹",
|
||||
"folders": "文件夹",
|
||||
"tabs": {
|
||||
"recommended": "推荐",
|
||||
"browse": "浏览",
|
||||
"collections": "合集",
|
||||
"playlists": "播放列表"
|
||||
},
|
||||
"groupings": {
|
||||
"all": "全部",
|
||||
"movies": "电影",
|
||||
"shows": "剧集",
|
||||
"seasons": "季",
|
||||
"episodes": "集",
|
||||
"folders": "文件夹"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"title": "关于",
|
||||
"openSourceLicenses": "开源许可证",
|
||||
"versionLabel": "版本 ${version}",
|
||||
"appDescription": "一款精美的 Flutter Plex 客户端",
|
||||
"viewLicensesDescription": "查看第三方库的许可证"
|
||||
},
|
||||
"serverSelection": {
|
||||
"connectingToServer": "正在连接服务器...",
|
||||
"connectingToAllServers": "正在连接所有服务器...",
|
||||
"connectToAllServers": "连接所有服务器",
|
||||
"allServerConnectionsFailed": "无法连接到任何服务器。请检查你的网络并重试。",
|
||||
"foundServers": "找到 ${count} 个服务器",
|
||||
"orSelectIndividually": "或单独选择一个服务器:",
|
||||
"serverDebugCopied": "服务器调试数据已复制到剪贴板",
|
||||
"copyDebugData": "复制调试数据",
|
||||
"noServersFound": "未找到服务器",
|
||||
"malformedServerData": "找到 ${count} 个数据格式错误的服务器。没有可用的有效服务器。",
|
||||
"incompleteServerInfo": "某些服务器信息不完整,已被跳过。请检查你的 Plex.tv 账户。",
|
||||
"incompleteConnectionInfo": "服务器连接信息不完整。请重试。",
|
||||
"malformedServerInfo": "服务器信息格式错误: ${message}",
|
||||
"networkConnectionFailed": "网络连接失败。请检查你的网络连接并重试。",
|
||||
"authenticationFailed": "验证失败。请重新登录。",
|
||||
"plexServiceUnavailable": "Plex 服务不可用。请稍后重试。",
|
||||
"failedToLoadServers": "无法加载服务器: ${error}"
|
||||
},
|
||||
"hubDetail": {
|
||||
"title": "标题",
|
||||
"releaseYear": "发行年份",
|
||||
"dateAdded": "添加日期",
|
||||
"rating": "评分",
|
||||
"noItemsFound": "未找到项目"
|
||||
},
|
||||
"logs": {
|
||||
"title": "日志",
|
||||
"clearLogs": "清除日志",
|
||||
"copyLogs": "复制日志",
|
||||
"exportLogs": "导出日志",
|
||||
"noLogsToShow": "没有可显示的日志",
|
||||
"error": "错误:",
|
||||
"stackTrace": "堆栈跟踪 (Stack Trace):"
|
||||
},
|
||||
"licenses": {
|
||||
"relatedPackages": "相关软件包",
|
||||
"license": "许可证",
|
||||
"licenseNumber": "许可证 ${number}",
|
||||
"licensesCount": "${count} 个许可证"
|
||||
},
|
||||
"navigation": {
|
||||
"home": "主页",
|
||||
"search": "搜索",
|
||||
"libraries": "媒体库",
|
||||
"settings": "设置"
|
||||
},
|
||||
"playlists": {
|
||||
"title": "播放列表",
|
||||
"noPlaylists": "未找到播放列表",
|
||||
"create": "创建播放列表",
|
||||
"newPlaylist": "新播放列表",
|
||||
"playlistName": "播放列表名称",
|
||||
"enterPlaylistName": "输入播放列表名称",
|
||||
"edit": "编辑播放列表",
|
||||
"delete": "删除播放列表",
|
||||
"addTo": "添加到播放列表",
|
||||
"addItems": "添加项目",
|
||||
"removeItem": "从播放列表中移除",
|
||||
"clearPlaylist": "清空播放列表",
|
||||
"playAll": "全部播放",
|
||||
"shuffle": "随机播放",
|
||||
"smartPlaylist": "智能播放列表",
|
||||
"regularPlaylist": "普通播放列表",
|
||||
"itemCount": "${count} 个项目",
|
||||
"oneItem": "1 个项目",
|
||||
"emptyPlaylist": "此播放列表为空",
|
||||
"deleteConfirm": "删除播放列表?",
|
||||
"deleteMessage": "确定要删除 \"${name}\" 吗?",
|
||||
"created": "播放列表已创建",
|
||||
"updated": "播放列表已更新",
|
||||
"deleted": "播放列表已删除",
|
||||
"itemAdded": "已添加到播放列表",
|
||||
"itemRemoved": "已从播放列表中移除",
|
||||
"selectPlaylist": "选择播放列表",
|
||||
"createNewPlaylist": "创建新播放列表",
|
||||
"errorCreating": "创建播放列表失败",
|
||||
"errorDeleting": "删除播放列表失败",
|
||||
"errorLoading": "加载播放列表失败",
|
||||
"errorAdding": "添加到播放列表失败",
|
||||
"errorReordering": "重新排序播放列表项目失败",
|
||||
"errorRemoving": "从播放列表中移除失败",
|
||||
"playlist": "播放列表"
|
||||
},
|
||||
"collections": {
|
||||
"title": "合集",
|
||||
"collection": "合集",
|
||||
"empty": "合集为空",
|
||||
"noItems": "此合集没有项目",
|
||||
"unknownLibrarySection": "无法删除:未知的媒体库分区",
|
||||
"deleteCollection": "删除合集",
|
||||
"deleteConfirm": "确定要删除\"${title}\"吗?此操作无法撤销。",
|
||||
"deleted": "已删除合集",
|
||||
"deleteFailed": "删除合集失败",
|
||||
"deleteFailedWithError": "删除合集失败:${error}",
|
||||
"failedToLoadItems": "加载合集项目失败:${error}",
|
||||
"addTo": "添加到合集",
|
||||
"selectCollection": "选择合集",
|
||||
"createNewCollection": "创建新合集",
|
||||
"collectionName": "合集名称",
|
||||
"enterCollectionName": "输入合集名称",
|
||||
"addedToCollection": "已添加到合集",
|
||||
"errorAddingToCollection": "添加到合集失败",
|
||||
"created": "已创建合集",
|
||||
"removeFromCollection": "从合集移除",
|
||||
"removeFromCollectionConfirm": "将“${title}”从此合集移除?",
|
||||
"removedFromCollection": "已从合集移除",
|
||||
"removeFromCollectionFailed": "从合集移除失败",
|
||||
"removeFromCollectionError": "从合集移除时出错:${error}"
|
||||
}
|
||||
}
|
||||
@@ -3,24 +3,44 @@ import 'dart:io' show Platform;
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'screens/main_screen.dart';
|
||||
import 'screens/auth_screen.dart';
|
||||
import 'services/storage_service.dart';
|
||||
import 'services/plex_auth_service.dart';
|
||||
import 'services/server_connection_service.dart';
|
||||
import 'services/macos_titlebar_service.dart';
|
||||
import 'services/fullscreen_state_manager.dart';
|
||||
import 'services/update_service.dart';
|
||||
import 'services/settings_service.dart';
|
||||
import 'providers/user_profile_provider.dart';
|
||||
import 'providers/plex_client_provider.dart';
|
||||
import 'providers/multi_server_provider.dart';
|
||||
import 'providers/server_state_provider.dart';
|
||||
import 'providers/theme_provider.dart';
|
||||
import 'providers/settings_provider.dart';
|
||||
import 'providers/hidden_libraries_provider.dart';
|
||||
import 'providers/playback_state_provider.dart';
|
||||
import 'services/multi_server_manager.dart';
|
||||
import 'services/data_aggregation_service.dart';
|
||||
import 'services/server_registry.dart';
|
||||
import 'utils/language_codes.dart';
|
||||
import 'utils/app_logger.dart';
|
||||
import 'utils/provider_extensions.dart';
|
||||
import 'utils/orientation_helper.dart';
|
||||
import 'i18n/strings.g.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Initialize settings first to get saved locale
|
||||
final settings = await SettingsService.getInstance();
|
||||
final savedLocale = settings.getAppLocale();
|
||||
|
||||
// Initialize localization with saved locale
|
||||
LocaleSettings.setLocale(savedLocale);
|
||||
|
||||
// Configure image cache for large libraries
|
||||
PaintingBinding.instance.imageCache.maximumSizeBytes = 500 << 20; // 500MB
|
||||
PaintingBinding.instance.imageCache.maximumSize = 500; // 500 images
|
||||
|
||||
// Initialize window_manager for desktop platforms
|
||||
if (Platform.isMacOS || Platform.isWindows || Platform.isLinux) {
|
||||
await windowManager.ensureInitialized();
|
||||
@@ -39,6 +59,10 @@ void main() async {
|
||||
// Initialize language codes for track selection
|
||||
await LanguageCodes.initialize();
|
||||
|
||||
// Initialize logger level based on debug setting
|
||||
final debugEnabled = settings.getEnableDebugLogging();
|
||||
setLoggerLevel(debugEnabled);
|
||||
|
||||
// Start global fullscreen state monitoring
|
||||
FullscreenStateManager().startMonitoring();
|
||||
|
||||
@@ -55,24 +79,41 @@ class MainApp extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Initialize multi-server infrastructure
|
||||
final serverManager = MultiServerManager();
|
||||
final aggregationService = DataAggregationService(serverManager);
|
||||
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
// Legacy provider for backward compatibility
|
||||
ChangeNotifierProvider(create: (context) => PlexClientProvider()),
|
||||
// New multi-server providers
|
||||
ChangeNotifierProvider(
|
||||
create: (context) =>
|
||||
MultiServerProvider(serverManager, aggregationService),
|
||||
),
|
||||
ChangeNotifierProvider(create: (context) => ServerStateProvider()),
|
||||
// Existing providers
|
||||
ChangeNotifierProvider(
|
||||
create: (context) => UserProfileProvider()..initialize(),
|
||||
),
|
||||
ChangeNotifierProvider(create: (context) => ThemeProvider()),
|
||||
ChangeNotifierProvider(create: (context) => SettingsProvider()),
|
||||
ChangeNotifierProvider(create: (context) => HiddenLibrariesProvider()),
|
||||
ChangeNotifierProvider(create: (context) => PlaybackStateProvider()),
|
||||
],
|
||||
child: Consumer<ThemeProvider>(
|
||||
builder: (context, themeProvider, child) {
|
||||
return MaterialApp(
|
||||
title: 'Plezy',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: themeProvider.lightTheme,
|
||||
darkTheme: themeProvider.darkTheme,
|
||||
themeMode: themeProvider.materialThemeMode,
|
||||
navigatorObservers: [routeObserver],
|
||||
home: const OrientationAwareSetup(),
|
||||
return TranslationProvider(
|
||||
child: MaterialApp(
|
||||
title: t.app.title,
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: themeProvider.lightTheme,
|
||||
darkTheme: themeProvider.darkTheme,
|
||||
themeMode: themeProvider.materialThemeMode,
|
||||
navigatorObservers: [routeObserver],
|
||||
home: const OrientationAwareSetup(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -118,97 +159,188 @@ class _SetupScreenState extends State<SetupScreen> {
|
||||
_loadSavedCredentials();
|
||||
}
|
||||
|
||||
void _checkForUpdatesOnStartup() async {
|
||||
// Delay slightly to allow UI to settle
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
try {
|
||||
final updateInfo = await UpdateService.checkForUpdatesOnStartup();
|
||||
|
||||
if (updateInfo != null && updateInfo['hasUpdate'] == true && mounted) {
|
||||
_showUpdateDialog(updateInfo);
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.e('Error checking for updates', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
void _showUpdateDialog(Map<String, dynamic> updateInfo) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext dialogContext) {
|
||||
return AlertDialog(
|
||||
title: Text(t.update.available),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
t.update.versionAvailable(version: updateInfo['latestVersion']),
|
||||
style: Theme.of(dialogContext).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
t.update.currentVersion(version: updateInfo['currentVersion']),
|
||||
style: Theme.of(dialogContext).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(dialogContext);
|
||||
},
|
||||
child: Text(t.common.later),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await UpdateService.skipVersion(updateInfo['latestVersion']);
|
||||
if (dialogContext.mounted) {
|
||||
Navigator.pop(dialogContext);
|
||||
}
|
||||
},
|
||||
child: Text(t.update.skipVersion),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () async {
|
||||
final url = Uri.parse(updateInfo['releaseUrl']);
|
||||
if (await canLaunchUrl(url)) {
|
||||
await launchUrl(url, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
if (dialogContext.mounted) {
|
||||
Navigator.pop(dialogContext);
|
||||
}
|
||||
},
|
||||
child: Text(t.update.viewRelease),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadSavedCredentials() async {
|
||||
final storage = await StorageService.getInstance();
|
||||
final registry = ServerRegistry(storage);
|
||||
|
||||
// Check if we have server data
|
||||
final serverData = storage.getServerData();
|
||||
final clientId = storage.getClientIdentifier();
|
||||
final plexToken = storage.getPlexToken();
|
||||
// Migrate from single-server to multi-server if needed
|
||||
await registry.migrateFromSingleServer();
|
||||
|
||||
// Get current user's server token (prioritize over original server token)
|
||||
final currentUserToken = storage.getToken();
|
||||
// Load enabled servers
|
||||
final servers = await registry.getEnabledServers();
|
||||
|
||||
if (serverData != null && clientId != null) {
|
||||
try {
|
||||
// Recreate PlexServer from stored data
|
||||
final server = PlexServer.fromJson(serverData);
|
||||
|
||||
// Use current user's token if available, fallback to server's original token
|
||||
final tokenToUse = currentUserToken ?? server.accessToken;
|
||||
|
||||
appLogger.d(
|
||||
'App startup token selection: currentUserToken=${currentUserToken != null ? 'present' : 'null'}, using=${currentUserToken != null ? 'current user' : 'original server'} token',
|
||||
if (servers.isEmpty) {
|
||||
// No servers configured - show auth screen
|
||||
if (mounted) {
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const AuthScreen()),
|
||||
);
|
||||
|
||||
// Create updated server with correct token for current user
|
||||
final serverWithCurrentToken = PlexServer(
|
||||
name: server.name,
|
||||
clientIdentifier: server.clientIdentifier,
|
||||
accessToken: tokenToUse,
|
||||
connections: server.connections,
|
||||
owned: server.owned,
|
||||
product: server.product,
|
||||
platform: server.platform,
|
||||
lastSeenAt: server.lastSeenAt,
|
||||
presence: server.presence,
|
||||
);
|
||||
|
||||
// Connect using the optimized service with current user's token
|
||||
final result = await ServerConnectionService.connectToServer(
|
||||
serverWithCurrentToken,
|
||||
clientIdentifier: clientId,
|
||||
verifyServer: true,
|
||||
fetchUserProfile: plexToken != null,
|
||||
plexToken: plexToken,
|
||||
);
|
||||
|
||||
// Handle result
|
||||
if (result.isSuccess) {
|
||||
// Success! Set client in provider and navigate to main screen
|
||||
if (mounted) {
|
||||
context.plexClient.setClient(result.client!);
|
||||
|
||||
if (mounted) {
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => MainScreen(client: result.client!),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
// Connection failed, clear credentials
|
||||
await storage.clearCredentials();
|
||||
}
|
||||
} catch (e) {
|
||||
// Error loading or testing server
|
||||
appLogger.e('Error during auto-login', error: e);
|
||||
await storage.clearCredentials();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// No saved credentials or auto-login failed - show auth screen
|
||||
if (mounted) {
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const AuthScreen()),
|
||||
// Get multi-server provider
|
||||
if (!mounted) return;
|
||||
final multiServerProvider = Provider.of<MultiServerProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
|
||||
try {
|
||||
appLogger.i('Connecting to ${servers.length} enabled servers...');
|
||||
|
||||
// Get or generate client identifier
|
||||
final clientId = storage.getClientIdentifier();
|
||||
|
||||
// Connect to all servers in parallel
|
||||
final connectedCount = await multiServerProvider.serverManager
|
||||
.connectToAllServers(
|
||||
servers,
|
||||
clientIdentifier: clientId,
|
||||
timeout: const Duration(seconds: 10),
|
||||
onServerConnected: (serverId, client) {
|
||||
// Set first connected client in legacy provider for backward compatibility
|
||||
final legacyProvider = Provider.of<PlexClientProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
if (legacyProvider.client == null) {
|
||||
legacyProvider.setClient(client);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (connectedCount > 0) {
|
||||
// At least one server connected successfully
|
||||
appLogger.i('Successfully connected to $connectedCount servers');
|
||||
|
||||
if (mounted) {
|
||||
// Check for updates BEFORE navigation
|
||||
_checkForUpdatesOnStartup();
|
||||
|
||||
// Navigate to main screen
|
||||
// Get first connected client for backward compatibility
|
||||
final firstClient =
|
||||
multiServerProvider.serverManager.onlineClients.values.first;
|
||||
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => MainScreen(client: firstClient),
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// All connections failed
|
||||
appLogger.w('Failed to connect to any servers');
|
||||
|
||||
if (mounted) {
|
||||
// Show auth screen to re-authenticate
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const AuthScreen()),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
appLogger.e(
|
||||
'Error during multi-server connection',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const AuthScreen()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Scaffold(
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('Loading...'),
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: 16),
|
||||
Text(t.app.loading),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../client/plex_client.dart';
|
||||
import '../models/plex_library.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
|
||||
/// Mixin providing common functionality for library tab screens
|
||||
/// Provides server-specific client resolution for multi-server support
|
||||
mixin LibraryTabStateMixin<T extends StatefulWidget> on State<T> {
|
||||
/// The library being displayed
|
||||
PlexLibrary get library;
|
||||
|
||||
/// Get the correct PlexClient for this library's server
|
||||
/// Throws an exception if no client is available
|
||||
PlexClient getClientForLibrary() => context.getClientForLibrary(library);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
import 'plex_metadata.dart';
|
||||
import 'plex_library.dart';
|
||||
|
||||
part 'media_container.g.dart';
|
||||
|
||||
@JsonSerializable()
|
||||
class MediaContainer<T> {
|
||||
final int? size;
|
||||
final int? totalSize;
|
||||
final int? offset;
|
||||
final String? identifier;
|
||||
@JsonKey(name: 'Directory')
|
||||
final List<PlexLibrary>? directories;
|
||||
@JsonKey(name: 'Metadata')
|
||||
final List<PlexMetadata>? metadata;
|
||||
|
||||
MediaContainer({
|
||||
this.size,
|
||||
this.totalSize,
|
||||
this.offset,
|
||||
this.identifier,
|
||||
this.directories,
|
||||
this.metadata,
|
||||
});
|
||||
|
||||
factory MediaContainer.fromJson(Map<String, dynamic> json) =>
|
||||
_$MediaContainerFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$MediaContainerToJson(this);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'media_container.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
MediaContainer<T> _$MediaContainerFromJson<T>(Map<String, dynamic> json) =>
|
||||
MediaContainer<T>(
|
||||
size: (json['size'] as num?)?.toInt(),
|
||||
totalSize: (json['totalSize'] as num?)?.toInt(),
|
||||
offset: (json['offset'] as num?)?.toInt(),
|
||||
identifier: json['identifier'] as String?,
|
||||
directories: (json['Directory'] as List<dynamic>?)
|
||||
?.map((e) => PlexLibrary.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
metadata: (json['Metadata'] as List<dynamic>?)
|
||||
?.map((e) => PlexMetadata.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$MediaContainerToJson<T>(MediaContainer<T> instance) =>
|
||||
<String, dynamic>{
|
||||
'size': instance.size,
|
||||
'totalSize': instance.totalSize,
|
||||
'offset': instance.offset,
|
||||
'identifier': instance.identifier,
|
||||
'Directory': instance.directories,
|
||||
'Metadata': instance.metadata,
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
/// Mixin that provides multi-server support fields for models.
|
||||
///
|
||||
/// This mixin adds serverId and serverName fields that are excluded from
|
||||
/// JSON serialization but can be used to track which server an item belongs to.
|
||||
mixin MultiServerFields {
|
||||
/// Server machine identifier (not from API)
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
String? get serverId;
|
||||
|
||||
/// Server display name (not from API)
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
String? get serverName;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
import 'plex_metadata.dart';
|
||||
|
||||
part 'play_queue_response.g.dart';
|
||||
|
||||
/// Converter to handle both int (0/1) and bool values from Plex API
|
||||
class BoolOrIntConverter implements JsonConverter<bool, Object> {
|
||||
const BoolOrIntConverter();
|
||||
|
||||
@override
|
||||
bool fromJson(Object json) {
|
||||
if (json is bool) return json;
|
||||
if (json is int) return json != 0;
|
||||
if (json is String) return json.toLowerCase() == 'true' || json == '1';
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
Object toJson(bool object) => object;
|
||||
}
|
||||
|
||||
/// Response from Plex play queue API
|
||||
/// Contains queue metadata and a window of items
|
||||
@JsonSerializable(createToJson: false)
|
||||
class PlayQueueResponse {
|
||||
final int playQueueID;
|
||||
final int? playQueueSelectedItemID;
|
||||
final int? playQueueSelectedItemOffset;
|
||||
final String? playQueueSelectedMetadataItemID;
|
||||
@BoolOrIntConverter()
|
||||
final bool playQueueShuffled;
|
||||
final String? playQueueSourceURI;
|
||||
final int? playQueueTotalCount;
|
||||
final int playQueueVersion;
|
||||
final int? size; // Number of items in this response window
|
||||
@JsonKey(name: 'Metadata')
|
||||
final List<PlexMetadata>? items;
|
||||
|
||||
PlayQueueResponse({
|
||||
required this.playQueueID,
|
||||
this.playQueueSelectedItemID,
|
||||
this.playQueueSelectedItemOffset,
|
||||
this.playQueueSelectedMetadataItemID,
|
||||
required this.playQueueShuffled,
|
||||
this.playQueueSourceURI,
|
||||
required this.playQueueTotalCount,
|
||||
required this.playQueueVersion,
|
||||
this.size,
|
||||
this.items,
|
||||
});
|
||||
|
||||
factory PlayQueueResponse.fromJson(
|
||||
Map<String, dynamic> json, {
|
||||
String? serverId,
|
||||
String? serverName,
|
||||
}) {
|
||||
// The API returns data wrapped in MediaContainer
|
||||
final container = json['MediaContainer'] as Map<String, dynamic>? ?? json;
|
||||
final response = _$PlayQueueResponseFromJson(container);
|
||||
|
||||
// Tag all items with server info
|
||||
if (response.items != null && (serverId != null || serverName != null)) {
|
||||
final taggedItems = response.items!
|
||||
.map(
|
||||
(item) => item.copyWith(serverId: serverId, serverName: serverName),
|
||||
)
|
||||
.toList();
|
||||
return PlayQueueResponse(
|
||||
playQueueID: response.playQueueID,
|
||||
playQueueSelectedItemID: response.playQueueSelectedItemID,
|
||||
playQueueSelectedItemOffset: response.playQueueSelectedItemOffset,
|
||||
playQueueSelectedMetadataItemID:
|
||||
response.playQueueSelectedMetadataItemID,
|
||||
playQueueShuffled: response.playQueueShuffled,
|
||||
playQueueSourceURI: response.playQueueSourceURI,
|
||||
playQueueTotalCount: response.playQueueTotalCount,
|
||||
playQueueVersion: response.playQueueVersion,
|
||||
size: response.size,
|
||||
items: taggedItems,
|
||||
);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/// Get the current selected item from the queue
|
||||
PlexMetadata? get selectedItem {
|
||||
if (items == null || playQueueSelectedItemID == null) return null;
|
||||
try {
|
||||
return items!.firstWhere(
|
||||
(item) => item.playQueueItemID == playQueueSelectedItemID,
|
||||
);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the index of the selected item in the current window
|
||||
int? get selectedItemIndex {
|
||||
if (items == null || playQueueSelectedItemID == null) return null;
|
||||
return items!.indexWhere(
|
||||
(item) => item.playQueueItemID == playQueueSelectedItemID,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'play_queue_response.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
PlayQueueResponse _$PlayQueueResponseFromJson(Map<String, dynamic> json) =>
|
||||
PlayQueueResponse(
|
||||
playQueueID: (json['playQueueID'] as num).toInt(),
|
||||
playQueueSelectedItemID: (json['playQueueSelectedItemID'] as num?)
|
||||
?.toInt(),
|
||||
playQueueSelectedItemOffset: (json['playQueueSelectedItemOffset'] as num?)
|
||||
?.toInt(),
|
||||
playQueueSelectedMetadataItemID:
|
||||
json['playQueueSelectedMetadataItemID'] as String?,
|
||||
playQueueShuffled: const BoolOrIntConverter().fromJson(
|
||||
json['playQueueShuffled'] as Object,
|
||||
),
|
||||
playQueueSourceURI: json['playQueueSourceURI'] as String?,
|
||||
playQueueTotalCount: (json['playQueueTotalCount'] as num?)?.toInt(),
|
||||
playQueueVersion: (json['playQueueVersion'] as num).toInt(),
|
||||
size: (json['size'] as num?)?.toInt(),
|
||||
items: (json['Metadata'] as List<dynamic>?)
|
||||
?.map((e) => PlexMetadata.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
@@ -0,0 +1,152 @@
|
||||
class PlexFileInfo {
|
||||
// Media level properties
|
||||
final String? container;
|
||||
final String? videoCodec;
|
||||
final String? videoResolution;
|
||||
final String? videoFrameRate;
|
||||
final String? videoProfile;
|
||||
final int? width;
|
||||
final int? height;
|
||||
final double? aspectRatio;
|
||||
final int? bitrate;
|
||||
final int? duration;
|
||||
final String? audioCodec;
|
||||
final String? audioProfile;
|
||||
final int? audioChannels;
|
||||
final bool? optimizedForStreaming;
|
||||
final bool? has64bitOffsets;
|
||||
|
||||
// Part level properties (file)
|
||||
final String? filePath;
|
||||
final int? fileSize;
|
||||
|
||||
// Stream level properties (video stream details)
|
||||
final String? colorSpace;
|
||||
final String? colorRange;
|
||||
final String? colorPrimaries;
|
||||
final String? colorTrc;
|
||||
final String? chromaSubsampling;
|
||||
final double? frameRate;
|
||||
final int? bitDepth;
|
||||
final String? audioChannelLayout;
|
||||
|
||||
PlexFileInfo({
|
||||
this.container,
|
||||
this.videoCodec,
|
||||
this.videoResolution,
|
||||
this.videoFrameRate,
|
||||
this.videoProfile,
|
||||
this.width,
|
||||
this.height,
|
||||
this.aspectRatio,
|
||||
this.bitrate,
|
||||
this.duration,
|
||||
this.audioCodec,
|
||||
this.audioProfile,
|
||||
this.audioChannels,
|
||||
this.optimizedForStreaming,
|
||||
this.has64bitOffsets,
|
||||
this.filePath,
|
||||
this.fileSize,
|
||||
this.colorSpace,
|
||||
this.colorRange,
|
||||
this.colorPrimaries,
|
||||
this.colorTrc,
|
||||
this.chromaSubsampling,
|
||||
this.frameRate,
|
||||
this.bitDepth,
|
||||
this.audioChannelLayout,
|
||||
});
|
||||
|
||||
/// Format file size in human-readable format (GB, MB, KB, bytes)
|
||||
String get fileSizeFormatted {
|
||||
if (fileSize == null) return 'Unknown';
|
||||
|
||||
const kb = 1024;
|
||||
const mb = kb * 1024;
|
||||
const gb = mb * 1024;
|
||||
|
||||
if (fileSize! >= gb) {
|
||||
return '${(fileSize! / gb).toStringAsFixed(2)} GB';
|
||||
} else if (fileSize! >= mb) {
|
||||
return '${(fileSize! / mb).toStringAsFixed(2)} MB';
|
||||
} else if (fileSize! >= kb) {
|
||||
return '${(fileSize! / kb).toStringAsFixed(2)} KB';
|
||||
} else {
|
||||
return '$fileSize bytes';
|
||||
}
|
||||
}
|
||||
|
||||
/// Format duration in HH:MM:SS or MM:SS format
|
||||
String get durationFormatted {
|
||||
if (duration == null) return 'Unknown';
|
||||
|
||||
final seconds = duration! ~/ 1000;
|
||||
final hours = seconds ~/ 3600;
|
||||
final minutes = (seconds % 3600) ~/ 60;
|
||||
final secs = seconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return '${hours}h ${minutes}m ${secs}s';
|
||||
} else {
|
||||
return '${minutes}m ${secs}s';
|
||||
}
|
||||
}
|
||||
|
||||
/// Format bitrate in Mbps or Kbps
|
||||
String get bitrateFormatted {
|
||||
if (bitrate == null) return 'Unknown';
|
||||
|
||||
const kbps = 1000;
|
||||
const mbps = kbps * 1000;
|
||||
|
||||
if (bitrate! >= mbps) {
|
||||
return '${(bitrate! / mbps).toStringAsFixed(2)} Mbps';
|
||||
} else if (bitrate! >= kbps) {
|
||||
return '${(bitrate! / kbps).toStringAsFixed(2)} Kbps';
|
||||
} else {
|
||||
return '$bitrate bps';
|
||||
}
|
||||
}
|
||||
|
||||
/// Format resolution as widthxheight
|
||||
String get resolutionFormatted {
|
||||
if (width != null && height != null) {
|
||||
return '${width}x$height';
|
||||
} else if (videoResolution != null) {
|
||||
return videoResolution!;
|
||||
}
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
/// Format aspect ratio
|
||||
String get aspectRatioFormatted {
|
||||
if (aspectRatio != null) {
|
||||
return aspectRatio!.toStringAsFixed(2);
|
||||
}
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
/// Format frame rate
|
||||
String get frameRateFormatted {
|
||||
if (frameRate != null) {
|
||||
return '${frameRate!.toStringAsFixed(3)} fps';
|
||||
} else if (videoFrameRate != null) {
|
||||
return videoFrameRate!;
|
||||
}
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
/// Format audio channels (e.g., "2 channels (stereo)")
|
||||
String get audioChannelsFormatted {
|
||||
if (audioChannels != null) {
|
||||
String channelText =
|
||||
'$audioChannels channel${audioChannels! > 1 ? 's' : ''}';
|
||||
if (audioChannelLayout != null) {
|
||||
channelText += ' ($audioChannelLayout)';
|
||||
}
|
||||
return channelText;
|
||||
}
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
@@ -70,5 +70,5 @@ class PlexHomeUser {
|
||||
bool get isAdminUser => admin;
|
||||
bool get isRestrictedUser => restricted;
|
||||
bool get isGuestUser => guest;
|
||||
bool get requiresPassword => hasPassword;
|
||||
bool get requiresPassword => protected;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import 'plex_metadata.dart';
|
||||
|
||||
/// Represents a Plex hub/recommendation section (e.g., Trending Movies, Top Thrillers)
|
||||
class PlexHub {
|
||||
final String hubKey;
|
||||
final String title;
|
||||
final String type;
|
||||
final String? hubIdentifier;
|
||||
final int size;
|
||||
final bool more;
|
||||
final List<PlexMetadata> items;
|
||||
|
||||
// Multi-server support fields
|
||||
final String? serverId; // Server machine identifier
|
||||
final String? serverName; // Server display name
|
||||
|
||||
PlexHub({
|
||||
required this.hubKey,
|
||||
required this.title,
|
||||
required this.type,
|
||||
this.hubIdentifier,
|
||||
required this.size,
|
||||
required this.more,
|
||||
required this.items,
|
||||
this.serverId,
|
||||
this.serverName,
|
||||
});
|
||||
|
||||
factory PlexHub.fromJson(Map<String, dynamic> json) {
|
||||
final metadataList = <PlexMetadata>[];
|
||||
|
||||
// Helper function to parse entries from a JSON list
|
||||
void parseEntries(List? entries) {
|
||||
if (entries == null) return;
|
||||
for (final item in entries) {
|
||||
try {
|
||||
metadataList.add(PlexMetadata.fromJson(item));
|
||||
} catch (e) {
|
||||
// Skip items that fail to parse
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hubs can contain either Metadata or Directory entries
|
||||
parseEntries(json['Metadata'] as List?);
|
||||
parseEntries(json['Directory'] as List?);
|
||||
|
||||
return PlexHub(
|
||||
hubKey: json['key'] as String? ?? '',
|
||||
title: json['title'] as String? ?? 'Unknown',
|
||||
type: json['type'] as String? ?? 'hub',
|
||||
hubIdentifier: json['hubIdentifier'] as String?,
|
||||
size: (json['size'] as num?)?.toInt() ?? metadataList.length,
|
||||
more: json['more'] == true || json['more'] == 1,
|
||||
items: metadataList,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
import 'mixins/multi_server_fields.dart';
|
||||
|
||||
part 'plex_library.g.dart';
|
||||
|
||||
@JsonSerializable()
|
||||
class PlexLibrary {
|
||||
class PlexLibrary with MultiServerFields {
|
||||
final String key;
|
||||
final String title;
|
||||
final String type;
|
||||
@@ -13,6 +15,18 @@ class PlexLibrary {
|
||||
final String? uuid;
|
||||
final int? updatedAt;
|
||||
final int? createdAt;
|
||||
final int? hidden;
|
||||
|
||||
// Multi-server support fields (from MultiServerFields mixin)
|
||||
@override
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
final String? serverId;
|
||||
@override
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
final String? serverName;
|
||||
|
||||
/// Global unique identifier across all servers (serverId:key)
|
||||
String get globalKey => serverId != null ? '$serverId:$key' : key;
|
||||
|
||||
PlexLibrary({
|
||||
required this.key,
|
||||
@@ -24,10 +38,44 @@ class PlexLibrary {
|
||||
this.uuid,
|
||||
this.updatedAt,
|
||||
this.createdAt,
|
||||
this.hidden,
|
||||
this.serverId,
|
||||
this.serverName,
|
||||
});
|
||||
|
||||
factory PlexLibrary.fromJson(Map<String, dynamic> json) =>
|
||||
_$PlexLibraryFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$PlexLibraryToJson(this);
|
||||
|
||||
/// Create a copy of this library with optional field overrides
|
||||
PlexLibrary copyWith({
|
||||
String? key,
|
||||
String? title,
|
||||
String? type,
|
||||
String? agent,
|
||||
String? scanner,
|
||||
String? language,
|
||||
String? uuid,
|
||||
int? updatedAt,
|
||||
int? createdAt,
|
||||
int? hidden,
|
||||
String? serverId,
|
||||
String? serverName,
|
||||
}) {
|
||||
return PlexLibrary(
|
||||
key: key ?? this.key,
|
||||
title: title ?? this.title,
|
||||
type: type ?? this.type,
|
||||
agent: agent ?? this.agent,
|
||||
scanner: scanner ?? this.scanner,
|
||||
language: language ?? this.language,
|
||||
uuid: uuid ?? this.uuid,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
hidden: hidden ?? this.hidden,
|
||||
serverId: serverId ?? this.serverId,
|
||||
serverName: serverName ?? this.serverName,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ PlexLibrary _$PlexLibraryFromJson(Map<String, dynamic> json) => PlexLibrary(
|
||||
uuid: json['uuid'] as String?,
|
||||
updatedAt: (json['updatedAt'] as num?)?.toInt(),
|
||||
createdAt: (json['createdAt'] as num?)?.toInt(),
|
||||
hidden: (json['hidden'] as num?)?.toInt(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$PlexLibraryToJson(PlexLibrary instance) =>
|
||||
@@ -29,4 +30,5 @@ Map<String, dynamic> _$PlexLibraryToJson(PlexLibrary instance) =>
|
||||
'uuid': instance.uuid,
|
||||
'updatedAt': instance.updatedAt,
|
||||
'createdAt': instance.createdAt,
|
||||
'hidden': instance.hidden,
|
||||
};
|
||||
|
||||
@@ -12,13 +12,40 @@ class PlexMediaInfo {
|
||||
});
|
||||
}
|
||||
|
||||
class PlexAudioTrack {
|
||||
/// Mixin for building track labels with a consistent pattern
|
||||
mixin TrackLabelBuilder {
|
||||
int get id;
|
||||
int? get index;
|
||||
String? get displayTitle;
|
||||
String? get language;
|
||||
|
||||
/// Builds a label from the given parts
|
||||
/// If displayTitle is present, returns it
|
||||
/// Otherwise, combines language and additional parts
|
||||
String buildLabel(List<String> additionalParts) {
|
||||
if (displayTitle != null && displayTitle!.isNotEmpty) {
|
||||
return displayTitle!;
|
||||
}
|
||||
final parts = <String>[];
|
||||
if (language != null && language!.isNotEmpty) {
|
||||
parts.add(language!);
|
||||
}
|
||||
parts.addAll(additionalParts);
|
||||
return parts.isEmpty ? 'Track ${index ?? id}' : parts.join(' · ');
|
||||
}
|
||||
}
|
||||
|
||||
class PlexAudioTrack with TrackLabelBuilder {
|
||||
@override
|
||||
final int id;
|
||||
@override
|
||||
final int? index;
|
||||
final String? codec;
|
||||
@override
|
||||
final String? language;
|
||||
final String? languageCode;
|
||||
final String? title;
|
||||
@override
|
||||
final String? displayTitle;
|
||||
final int? channels;
|
||||
final bool selected;
|
||||
@@ -36,22 +63,24 @@ class PlexAudioTrack {
|
||||
});
|
||||
|
||||
String get label {
|
||||
if (displayTitle != null) return displayTitle!;
|
||||
final parts = <String>[];
|
||||
if (language != null) parts.add(language!);
|
||||
if (codec != null) parts.add(codec!.toUpperCase());
|
||||
if (channels != null) parts.add('${channels!}ch');
|
||||
return parts.isEmpty ? 'Track ${index ?? id}' : parts.join(' · ');
|
||||
final additionalParts = <String>[];
|
||||
if (codec != null) additionalParts.add(codec!.toUpperCase());
|
||||
if (channels != null) additionalParts.add('${channels!}ch');
|
||||
return buildLabel(additionalParts);
|
||||
}
|
||||
}
|
||||
|
||||
class PlexSubtitleTrack {
|
||||
class PlexSubtitleTrack with TrackLabelBuilder {
|
||||
@override
|
||||
final int id;
|
||||
@override
|
||||
final int? index;
|
||||
final String? codec;
|
||||
@override
|
||||
final String? language;
|
||||
final String? languageCode;
|
||||
final String? title;
|
||||
@override
|
||||
final String? displayTitle;
|
||||
final bool selected;
|
||||
final bool forced;
|
||||
@@ -71,11 +100,53 @@ class PlexSubtitleTrack {
|
||||
});
|
||||
|
||||
String get label {
|
||||
if (displayTitle != null) return displayTitle!;
|
||||
final parts = <String>[];
|
||||
if (language != null) parts.add(language!);
|
||||
if (forced) parts.add('Forced');
|
||||
return parts.isEmpty ? 'Track ${index ?? id}' : parts.join(' · ');
|
||||
final additionalParts = <String>[];
|
||||
if (forced) additionalParts.add('Forced');
|
||||
return buildLabel(additionalParts);
|
||||
}
|
||||
|
||||
/// Returns true if this subtitle track is an external file (sidecar subtitle)
|
||||
/// External subtitles have a key property that points to /library/streams/{id}
|
||||
bool get isExternal => key != null && key!.isNotEmpty;
|
||||
|
||||
/// Constructs the full URL for fetching external subtitle files
|
||||
/// Returns null if this is not an external subtitle
|
||||
String? getSubtitleUrl(String baseUrl, String token) {
|
||||
if (!isExternal) return null;
|
||||
|
||||
// Determine file extension based on codec
|
||||
final ext = _getExtensionFromCodec(codec);
|
||||
|
||||
// Construct URL with authentication token
|
||||
return '$baseUrl$key.$ext?X-Plex-Token=$token';
|
||||
}
|
||||
|
||||
/// Maps Plex subtitle codec names to file extensions
|
||||
String _getExtensionFromCodec(String? codec) {
|
||||
if (codec == null) return 'srt';
|
||||
|
||||
switch (codec.toLowerCase()) {
|
||||
case 'subrip':
|
||||
case 'srt':
|
||||
return 'srt';
|
||||
case 'ass':
|
||||
return 'ass';
|
||||
case 'ssa':
|
||||
return 'ssa';
|
||||
case 'webvtt':
|
||||
case 'vtt':
|
||||
return 'vtt';
|
||||
case 'mov_text':
|
||||
return 'srt';
|
||||
case 'pgs':
|
||||
case 'hdmv_pgs_subtitle':
|
||||
return 'sup';
|
||||
case 'dvd_subtitle':
|
||||
case 'dvdsub':
|
||||
return 'sub';
|
||||
default:
|
||||
return 'srt'; // Default to SRT for unknown codecs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,3 +173,28 @@ class PlexChapter {
|
||||
Duration? get endTime =>
|
||||
endTimeOffset != null ? Duration(milliseconds: endTimeOffset!) : null;
|
||||
}
|
||||
|
||||
class PlexMarker {
|
||||
final int id;
|
||||
final String type;
|
||||
final int startTimeOffset;
|
||||
final int endTimeOffset;
|
||||
|
||||
PlexMarker({
|
||||
required this.id,
|
||||
required this.type,
|
||||
required this.startTimeOffset,
|
||||
required this.endTimeOffset,
|
||||
});
|
||||
|
||||
Duration get startTime => Duration(milliseconds: startTimeOffset);
|
||||
Duration get endTime => Duration(milliseconds: endTimeOffset);
|
||||
|
||||
bool get isIntro => type == 'intro';
|
||||
bool get isCredits => type == 'credits';
|
||||
|
||||
bool containsPosition(Duration position) {
|
||||
final posMs = position.inMilliseconds;
|
||||
return posMs >= startTimeOffset && posMs <= endTimeOffset;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
class PlexMediaVersion {
|
||||
final int id;
|
||||
final String? videoResolution;
|
||||
final String? videoCodec;
|
||||
final int? bitrate;
|
||||
final int? width;
|
||||
final int? height;
|
||||
final String? container;
|
||||
final String partKey;
|
||||
|
||||
PlexMediaVersion({
|
||||
required this.id,
|
||||
this.videoResolution,
|
||||
this.videoCodec,
|
||||
this.bitrate,
|
||||
this.width,
|
||||
this.height,
|
||||
this.container,
|
||||
required this.partKey,
|
||||
});
|
||||
|
||||
/// Creates a PlexMediaVersion from Plex API Media object
|
||||
factory PlexMediaVersion.fromJson(Map<String, dynamic> json) {
|
||||
// Get the first Part key for playback
|
||||
final parts = json['Part'] as List<dynamic>?;
|
||||
final partKey = parts != null && parts.isNotEmpty
|
||||
? parts[0]['key'] as String? ?? ''
|
||||
: '';
|
||||
|
||||
return PlexMediaVersion(
|
||||
id: json['id'] as int? ?? 0,
|
||||
videoResolution: json['videoResolution'] as String?,
|
||||
videoCodec: json['videoCodec'] as String?,
|
||||
bitrate: json['bitrate'] as int?,
|
||||
width: json['width'] as int?,
|
||||
height: json['height'] as int?,
|
||||
container: json['container'] as String?,
|
||||
partKey: partKey,
|
||||
);
|
||||
}
|
||||
|
||||
/// Display label with detailed information: "1080p H.264 MKV (8.5 Mbps)"
|
||||
String get displayLabel {
|
||||
final parts = <String>[];
|
||||
|
||||
// Add resolution
|
||||
if (videoResolution != null && videoResolution!.isNotEmpty) {
|
||||
parts.add('${videoResolution}p');
|
||||
} else if (height != null) {
|
||||
parts.add('${height}p');
|
||||
}
|
||||
|
||||
// Add codec
|
||||
if (videoCodec != null && videoCodec!.isNotEmpty) {
|
||||
parts.add(videoCodec!.toUpperCase());
|
||||
}
|
||||
|
||||
// Add container
|
||||
if (container != null && container!.isNotEmpty) {
|
||||
parts.add(container!.toUpperCase());
|
||||
}
|
||||
|
||||
// Build main label
|
||||
String label = parts.isNotEmpty ? parts.join(' ') : 'Unknown';
|
||||
|
||||
// Add bitrate in parentheses
|
||||
if (bitrate != null && bitrate! > 0) {
|
||||
final bitrateInMbps = (bitrate! / 1000).toStringAsFixed(1);
|
||||
label += ' ($bitrateInMbps Mbps)';
|
||||
}
|
||||
|
||||
return label;
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => displayLabel;
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
import 'mixins/multi_server_fields.dart';
|
||||
import 'plex_role.dart';
|
||||
|
||||
part 'plex_metadata.g.dart';
|
||||
|
||||
@JsonSerializable()
|
||||
class PlexMetadata {
|
||||
class PlexMetadata with MultiServerFields {
|
||||
final String ratingKey;
|
||||
final String key;
|
||||
final String? guid;
|
||||
@@ -12,18 +15,21 @@ class PlexMetadata {
|
||||
final String title;
|
||||
final String? contentRating;
|
||||
final String? summary;
|
||||
final int? rating;
|
||||
final double? rating;
|
||||
final double? audienceRating;
|
||||
final int? year;
|
||||
final String? thumb;
|
||||
final String? art;
|
||||
final int? duration;
|
||||
final int? addedAt;
|
||||
final int? updatedAt;
|
||||
final int? lastViewedAt; // Timestamp when item was last viewed
|
||||
final String? grandparentTitle; // Show title for episodes
|
||||
final String? grandparentThumb; // Show poster for episodes
|
||||
final String? grandparentArt; // Show art for episodes
|
||||
final String? grandparentRatingKey; // Show rating key for episodes
|
||||
final String? parentTitle; // Season title for episodes
|
||||
final String? parentThumb; // Season poster for episodes
|
||||
final String? parentRatingKey; // Season rating key for episodes
|
||||
final int? parentIndex; // Season number
|
||||
final int? index; // Episode number
|
||||
@@ -32,11 +38,30 @@ class PlexMetadata {
|
||||
final int? viewCount;
|
||||
final int? leafCount; // Total number of episodes in a series/season
|
||||
final int? viewedLeafCount; // Number of watched episodes in a series/season
|
||||
final int? childCount; // Number of items in a collection or playlist
|
||||
@JsonKey(name: 'Role')
|
||||
final List<PlexRole>? role; // Cast members
|
||||
final String? audioLanguage; // Per-media preferred audio language
|
||||
final String? subtitleLanguage; // Per-media preferred subtitle language
|
||||
final int? playlistItemID; // Playlist item ID (for dumb playlists only)
|
||||
final int? playQueueItemID; // Play queue item ID (unique even for duplicates)
|
||||
final int? librarySectionID; // Library section ID this item belongs to
|
||||
|
||||
// Multi-server support fields (from MultiServerFields mixin)
|
||||
@override
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
final String? serverId;
|
||||
@override
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
final String? serverName;
|
||||
|
||||
// Transient field for clear logo (extracted from Image array)
|
||||
String? _clearLogo;
|
||||
String? get clearLogo => _clearLogo;
|
||||
|
||||
/// Global unique identifier across all servers (serverId:ratingKey)
|
||||
String get globalKey => serverId != null ? '$serverId:$ratingKey' : ratingKey;
|
||||
|
||||
PlexMetadata({
|
||||
required this.ratingKey,
|
||||
required this.key,
|
||||
@@ -47,17 +72,20 @@ class PlexMetadata {
|
||||
this.contentRating,
|
||||
this.summary,
|
||||
this.rating,
|
||||
this.audienceRating,
|
||||
this.year,
|
||||
this.thumb,
|
||||
this.art,
|
||||
this.duration,
|
||||
this.addedAt,
|
||||
this.updatedAt,
|
||||
this.lastViewedAt,
|
||||
this.grandparentTitle,
|
||||
this.grandparentThumb,
|
||||
this.grandparentArt,
|
||||
this.grandparentRatingKey,
|
||||
this.parentTitle,
|
||||
this.parentThumb,
|
||||
this.parentRatingKey,
|
||||
this.parentIndex,
|
||||
this.index,
|
||||
@@ -66,8 +94,107 @@ class PlexMetadata {
|
||||
this.viewCount,
|
||||
this.leafCount,
|
||||
this.viewedLeafCount,
|
||||
this.childCount,
|
||||
this.role,
|
||||
this.audioLanguage,
|
||||
this.subtitleLanguage,
|
||||
this.playlistItemID,
|
||||
this.playQueueItemID,
|
||||
this.librarySectionID,
|
||||
this.serverId,
|
||||
this.serverName,
|
||||
});
|
||||
|
||||
/// Create a copy of this metadata with optional field overrides
|
||||
PlexMetadata copyWith({
|
||||
String? ratingKey,
|
||||
String? key,
|
||||
String? guid,
|
||||
String? studio,
|
||||
String? type,
|
||||
String? title,
|
||||
String? contentRating,
|
||||
String? summary,
|
||||
double? rating,
|
||||
double? audienceRating,
|
||||
int? year,
|
||||
String? thumb,
|
||||
String? art,
|
||||
int? duration,
|
||||
int? addedAt,
|
||||
int? updatedAt,
|
||||
int? lastViewedAt,
|
||||
String? grandparentTitle,
|
||||
String? grandparentThumb,
|
||||
String? grandparentArt,
|
||||
String? grandparentRatingKey,
|
||||
String? parentTitle,
|
||||
String? parentThumb,
|
||||
String? parentRatingKey,
|
||||
int? parentIndex,
|
||||
int? index,
|
||||
String? grandparentTheme,
|
||||
int? viewOffset,
|
||||
int? viewCount,
|
||||
int? leafCount,
|
||||
int? viewedLeafCount,
|
||||
int? childCount,
|
||||
List<PlexRole>? role,
|
||||
String? audioLanguage,
|
||||
String? subtitleLanguage,
|
||||
int? playlistItemID,
|
||||
int? playQueueItemID,
|
||||
int? librarySectionID,
|
||||
String? serverId,
|
||||
String? serverName,
|
||||
}) {
|
||||
final copy = PlexMetadata(
|
||||
ratingKey: ratingKey ?? this.ratingKey,
|
||||
key: key ?? this.key,
|
||||
guid: guid ?? this.guid,
|
||||
studio: studio ?? this.studio,
|
||||
type: type ?? this.type,
|
||||
title: title ?? this.title,
|
||||
contentRating: contentRating ?? this.contentRating,
|
||||
summary: summary ?? this.summary,
|
||||
rating: rating ?? this.rating,
|
||||
audienceRating: audienceRating ?? this.audienceRating,
|
||||
year: year ?? this.year,
|
||||
thumb: thumb ?? this.thumb,
|
||||
art: art ?? this.art,
|
||||
duration: duration ?? this.duration,
|
||||
addedAt: addedAt ?? this.addedAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
lastViewedAt: lastViewedAt ?? this.lastViewedAt,
|
||||
grandparentTitle: grandparentTitle ?? this.grandparentTitle,
|
||||
grandparentThumb: grandparentThumb ?? this.grandparentThumb,
|
||||
grandparentArt: grandparentArt ?? this.grandparentArt,
|
||||
grandparentRatingKey: grandparentRatingKey ?? this.grandparentRatingKey,
|
||||
parentTitle: parentTitle ?? this.parentTitle,
|
||||
parentThumb: parentThumb ?? this.parentThumb,
|
||||
parentRatingKey: parentRatingKey ?? this.parentRatingKey,
|
||||
parentIndex: parentIndex ?? this.parentIndex,
|
||||
index: index ?? this.index,
|
||||
grandparentTheme: grandparentTheme ?? this.grandparentTheme,
|
||||
viewOffset: viewOffset ?? this.viewOffset,
|
||||
viewCount: viewCount ?? this.viewCount,
|
||||
leafCount: leafCount ?? this.leafCount,
|
||||
viewedLeafCount: viewedLeafCount ?? this.viewedLeafCount,
|
||||
childCount: childCount ?? this.childCount,
|
||||
role: role ?? this.role,
|
||||
audioLanguage: audioLanguage ?? this.audioLanguage,
|
||||
subtitleLanguage: subtitleLanguage ?? this.subtitleLanguage,
|
||||
playlistItemID: playlistItemID ?? this.playlistItemID,
|
||||
playQueueItemID: playQueueItemID ?? this.playQueueItemID,
|
||||
librarySectionID: librarySectionID ?? this.librarySectionID,
|
||||
serverId: serverId ?? this.serverId,
|
||||
serverName: serverName ?? this.serverName,
|
||||
);
|
||||
// Preserve clearLogo
|
||||
copy._clearLogo = _clearLogo;
|
||||
return copy;
|
||||
}
|
||||
|
||||
// Extract clearLogo from Image array in raw JSON
|
||||
void _extractClearLogo(Map<String, dynamic> json) {
|
||||
if (!json.containsKey('Image')) return;
|
||||
@@ -121,12 +248,21 @@ class PlexMetadata {
|
||||
}
|
||||
|
||||
// Helper to get the poster (show poster for episodes/seasons, thumb otherwise)
|
||||
String? get posterThumb {
|
||||
// If useSeasonPoster is true, episodes will use season poster instead of series poster
|
||||
String? posterThumb({bool useSeasonPoster = false}) {
|
||||
final itemType = type.toLowerCase();
|
||||
|
||||
// For episodes and seasons, prefer grandparent thumb (show poster)
|
||||
if ((itemType == 'episode' || itemType == 'season') &&
|
||||
grandparentThumb != null) {
|
||||
if (itemType == 'episode') {
|
||||
// If season poster is enabled and available, use it
|
||||
if (useSeasonPoster && parentThumb != null) {
|
||||
return parentThumb!;
|
||||
}
|
||||
// Otherwise fall back to series poster, then item thumb
|
||||
if (grandparentThumb != null) {
|
||||
return grandparentThumb!;
|
||||
}
|
||||
} else if (itemType == 'season' && grandparentThumb != null) {
|
||||
// For seasons, always use series poster
|
||||
return grandparentThumb!;
|
||||
}
|
||||
return thumb;
|
||||
|
||||
@@ -15,18 +15,21 @@ PlexMetadata _$PlexMetadataFromJson(Map<String, dynamic> json) => PlexMetadata(
|
||||
title: json['title'] as String,
|
||||
contentRating: json['contentRating'] as String?,
|
||||
summary: json['summary'] as String?,
|
||||
rating: (json['rating'] as num?)?.toInt(),
|
||||
rating: (json['rating'] as num?)?.toDouble(),
|
||||
audienceRating: (json['audienceRating'] as num?)?.toDouble(),
|
||||
year: (json['year'] as num?)?.toInt(),
|
||||
thumb: json['thumb'] as String?,
|
||||
art: json['art'] as String?,
|
||||
duration: (json['duration'] as num?)?.toInt(),
|
||||
addedAt: (json['addedAt'] as num?)?.toInt(),
|
||||
updatedAt: (json['updatedAt'] as num?)?.toInt(),
|
||||
lastViewedAt: (json['lastViewedAt'] as num?)?.toInt(),
|
||||
grandparentTitle: json['grandparentTitle'] as String?,
|
||||
grandparentThumb: json['grandparentThumb'] as String?,
|
||||
grandparentArt: json['grandparentArt'] as String?,
|
||||
grandparentRatingKey: json['grandparentRatingKey'] as String?,
|
||||
parentTitle: json['parentTitle'] as String?,
|
||||
parentThumb: json['parentThumb'] as String?,
|
||||
parentRatingKey: json['parentRatingKey'] as String?,
|
||||
parentIndex: (json['parentIndex'] as num?)?.toInt(),
|
||||
index: (json['index'] as num?)?.toInt(),
|
||||
@@ -35,6 +38,15 @@ PlexMetadata _$PlexMetadataFromJson(Map<String, dynamic> json) => PlexMetadata(
|
||||
viewCount: (json['viewCount'] as num?)?.toInt(),
|
||||
leafCount: (json['leafCount'] as num?)?.toInt(),
|
||||
viewedLeafCount: (json['viewedLeafCount'] as num?)?.toInt(),
|
||||
childCount: (json['childCount'] as num?)?.toInt(),
|
||||
role: (json['Role'] as List<dynamic>?)
|
||||
?.map((e) => PlexRole.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
audioLanguage: json['audioLanguage'] as String?,
|
||||
subtitleLanguage: json['subtitleLanguage'] as String?,
|
||||
playlistItemID: (json['playlistItemID'] as num?)?.toInt(),
|
||||
playQueueItemID: (json['playQueueItemID'] as num?)?.toInt(),
|
||||
librarySectionID: (json['librarySectionID'] as num?)?.toInt(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$PlexMetadataToJson(PlexMetadata instance) =>
|
||||
@@ -48,17 +60,20 @@ Map<String, dynamic> _$PlexMetadataToJson(PlexMetadata instance) =>
|
||||
'contentRating': instance.contentRating,
|
||||
'summary': instance.summary,
|
||||
'rating': instance.rating,
|
||||
'audienceRating': instance.audienceRating,
|
||||
'year': instance.year,
|
||||
'thumb': instance.thumb,
|
||||
'art': instance.art,
|
||||
'duration': instance.duration,
|
||||
'addedAt': instance.addedAt,
|
||||
'updatedAt': instance.updatedAt,
|
||||
'lastViewedAt': instance.lastViewedAt,
|
||||
'grandparentTitle': instance.grandparentTitle,
|
||||
'grandparentThumb': instance.grandparentThumb,
|
||||
'grandparentArt': instance.grandparentArt,
|
||||
'grandparentRatingKey': instance.grandparentRatingKey,
|
||||
'parentTitle': instance.parentTitle,
|
||||
'parentThumb': instance.parentThumb,
|
||||
'parentRatingKey': instance.parentRatingKey,
|
||||
'parentIndex': instance.parentIndex,
|
||||
'index': instance.index,
|
||||
@@ -67,4 +82,11 @@ Map<String, dynamic> _$PlexMetadataToJson(PlexMetadata instance) =>
|
||||
'viewCount': instance.viewCount,
|
||||
'leafCount': instance.leafCount,
|
||||
'viewedLeafCount': instance.viewedLeafCount,
|
||||
'childCount': instance.childCount,
|
||||
'Role': instance.role,
|
||||
'audioLanguage': instance.audioLanguage,
|
||||
'subtitleLanguage': instance.subtitleLanguage,
|
||||
'playlistItemID': instance.playlistItemID,
|
||||
'playQueueItemID': instance.playQueueItemID,
|
||||
'librarySectionID': instance.librarySectionID,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
import 'mixins/multi_server_fields.dart';
|
||||
|
||||
part 'plex_playlist.g.dart';
|
||||
|
||||
@JsonSerializable()
|
||||
class PlexPlaylist with MultiServerFields {
|
||||
final String ratingKey;
|
||||
final String key;
|
||||
final String type; // "playlist"
|
||||
final String title;
|
||||
final String? summary;
|
||||
final bool smart;
|
||||
final String playlistType; // video, audio, photo
|
||||
final int? duration;
|
||||
final int? leafCount; // Number of items in playlist
|
||||
final String? composite; // Composite thumbnail image
|
||||
final int? addedAt;
|
||||
final int? updatedAt;
|
||||
final int? lastViewedAt;
|
||||
final int? viewCount;
|
||||
final String? content; // For smart playlists - generator URI
|
||||
final String? guid;
|
||||
final String? thumb;
|
||||
|
||||
// Multi-server support fields (from MultiServerFields mixin)
|
||||
@override
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
final String? serverId;
|
||||
@override
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
final String? serverName;
|
||||
|
||||
PlexPlaylist({
|
||||
required this.ratingKey,
|
||||
required this.key,
|
||||
required this.type,
|
||||
required this.title,
|
||||
this.summary,
|
||||
required this.smart,
|
||||
required this.playlistType,
|
||||
this.duration,
|
||||
this.leafCount,
|
||||
this.composite,
|
||||
this.addedAt,
|
||||
this.updatedAt,
|
||||
this.lastViewedAt,
|
||||
this.viewCount,
|
||||
this.content,
|
||||
this.guid,
|
||||
this.thumb,
|
||||
this.serverId,
|
||||
this.serverName,
|
||||
});
|
||||
|
||||
/// Helper to get display image (composite or thumb)
|
||||
String? get displayImage => composite ?? thumb;
|
||||
|
||||
/// Helper to get display title (consistent with PlexMetadata)
|
||||
String get displayTitle => title;
|
||||
|
||||
/// Helper to determine if playlist is editable
|
||||
bool get isEditable => !smart;
|
||||
|
||||
/// Get globally unique key across all servers
|
||||
String get globalKey => serverId != null ? '$serverId:$ratingKey' : ratingKey;
|
||||
|
||||
// Properties for MediaCard compatibility with PlexMetadata interface
|
||||
|
||||
/// Playlists are not "watched" in the traditional sense
|
||||
bool get isWatched => false;
|
||||
|
||||
/// Playlists don't have resume positions
|
||||
int? get viewOffset => null;
|
||||
|
||||
/// Playlists don't have parent/episode indices
|
||||
int? get parentIndex => null;
|
||||
int? get index => null;
|
||||
|
||||
/// Playlists don't have parent titles or subtitles
|
||||
String? get parentTitle => null;
|
||||
String? get displaySubtitle => null;
|
||||
|
||||
/// Playlists don't have year, rating, or content metadata
|
||||
int? get year => null;
|
||||
String? get contentRating => null;
|
||||
double? get rating => null;
|
||||
String? get studio => null;
|
||||
|
||||
/// Use leafCount as the equivalent of childCount
|
||||
int? get childCount => leafCount;
|
||||
|
||||
/// Playlists don't track viewed leaf count
|
||||
int? get viewedLeafCount => null;
|
||||
|
||||
factory PlexPlaylist.fromJson(Map<String, dynamic> json) =>
|
||||
_$PlexPlaylistFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$PlexPlaylistToJson(this);
|
||||
|
||||
/// Create a copy with optional field updates
|
||||
PlexPlaylist copyWith({
|
||||
String? ratingKey,
|
||||
String? key,
|
||||
String? type,
|
||||
String? title,
|
||||
String? summary,
|
||||
bool? smart,
|
||||
String? playlistType,
|
||||
int? duration,
|
||||
int? leafCount,
|
||||
String? composite,
|
||||
int? addedAt,
|
||||
int? updatedAt,
|
||||
int? lastViewedAt,
|
||||
int? viewCount,
|
||||
String? content,
|
||||
String? guid,
|
||||
String? thumb,
|
||||
String? serverId,
|
||||
String? serverName,
|
||||
}) {
|
||||
return PlexPlaylist(
|
||||
ratingKey: ratingKey ?? this.ratingKey,
|
||||
key: key ?? this.key,
|
||||
type: type ?? this.type,
|
||||
title: title ?? this.title,
|
||||
summary: summary ?? this.summary,
|
||||
smart: smart ?? this.smart,
|
||||
playlistType: playlistType ?? this.playlistType,
|
||||
duration: duration ?? this.duration,
|
||||
leafCount: leafCount ?? this.leafCount,
|
||||
composite: composite ?? this.composite,
|
||||
addedAt: addedAt ?? this.addedAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
lastViewedAt: lastViewedAt ?? this.lastViewedAt,
|
||||
viewCount: viewCount ?? this.viewCount,
|
||||
content: content ?? this.content,
|
||||
guid: guid ?? this.guid,
|
||||
thumb: thumb ?? this.thumb,
|
||||
serverId: serverId ?? this.serverId,
|
||||
serverName: serverName ?? this.serverName,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'plex_playlist.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
PlexPlaylist _$PlexPlaylistFromJson(Map<String, dynamic> json) => PlexPlaylist(
|
||||
ratingKey: json['ratingKey'] as String,
|
||||
key: json['key'] as String,
|
||||
type: json['type'] as String,
|
||||
title: json['title'] as String,
|
||||
summary: json['summary'] as String?,
|
||||
smart: json['smart'] as bool,
|
||||
playlistType: json['playlistType'] as String,
|
||||
duration: (json['duration'] as num?)?.toInt(),
|
||||
leafCount: (json['leafCount'] as num?)?.toInt(),
|
||||
composite: json['composite'] as String?,
|
||||
addedAt: (json['addedAt'] as num?)?.toInt(),
|
||||
updatedAt: (json['updatedAt'] as num?)?.toInt(),
|
||||
lastViewedAt: (json['lastViewedAt'] as num?)?.toInt(),
|
||||
viewCount: (json['viewCount'] as num?)?.toInt(),
|
||||
content: json['content'] as String?,
|
||||
guid: json['guid'] as String?,
|
||||
thumb: json['thumb'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$PlexPlaylistToJson(PlexPlaylist instance) =>
|
||||
<String, dynamic>{
|
||||
'ratingKey': instance.ratingKey,
|
||||
'key': instance.key,
|
||||
'type': instance.type,
|
||||
'title': instance.title,
|
||||
'summary': instance.summary,
|
||||
'smart': instance.smart,
|
||||
'playlistType': instance.playlistType,
|
||||
'duration': instance.duration,
|
||||
'leafCount': instance.leafCount,
|
||||
'composite': instance.composite,
|
||||
'addedAt': instance.addedAt,
|
||||
'updatedAt': instance.updatedAt,
|
||||
'lastViewedAt': instance.lastViewedAt,
|
||||
'viewCount': instance.viewCount,
|
||||
'content': instance.content,
|
||||
'guid': instance.guid,
|
||||
'thumb': instance.thumb,
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'plex_role.g.dart';
|
||||
|
||||
@JsonSerializable()
|
||||
class PlexRole {
|
||||
final int? id;
|
||||
final String? filter;
|
||||
final String tag;
|
||||
final String? tagKey;
|
||||
final String? role;
|
||||
final String? thumb;
|
||||
final int? count;
|
||||
|
||||
PlexRole({
|
||||
this.id,
|
||||
this.filter,
|
||||
required this.tag,
|
||||
this.tagKey,
|
||||
this.role,
|
||||
this.thumb,
|
||||
this.count,
|
||||
});
|
||||
|
||||
factory PlexRole.fromJson(Map<String, dynamic> json) =>
|
||||
_$PlexRoleFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$PlexRoleToJson(this);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'plex_role.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
PlexRole _$PlexRoleFromJson(Map<String, dynamic> json) => PlexRole(
|
||||
id: (json['id'] as num?)?.toInt(),
|
||||
filter: json['filter'] as String?,
|
||||
tag: json['tag'] as String,
|
||||
tagKey: json['tagKey'] as String?,
|
||||
role: json['role'] as String?,
|
||||
thumb: json['thumb'] as String?,
|
||||
count: (json['count'] as num?)?.toInt(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$PlexRoleToJson(PlexRole instance) => <String, dynamic>{
|
||||
'id': instance.id,
|
||||
'filter': instance.filter,
|
||||
'tag': instance.tag,
|
||||
'tagKey': instance.tagKey,
|
||||
'role': instance.role,
|
||||
'thumb': instance.thumb,
|
||||
'count': instance.count,
|
||||
};
|
||||
@@ -1,29 +0,0 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'plex_server_info.g.dart';
|
||||
|
||||
@JsonSerializable()
|
||||
class PlexServerInfo {
|
||||
final String name;
|
||||
final String? host;
|
||||
final int? port;
|
||||
final String? machineIdentifier;
|
||||
final String version;
|
||||
final bool? owned;
|
||||
final bool? https;
|
||||
|
||||
PlexServerInfo({
|
||||
required this.name,
|
||||
this.host,
|
||||
this.port,
|
||||
this.machineIdentifier,
|
||||
required this.version,
|
||||
this.owned,
|
||||
this.https,
|
||||
});
|
||||
|
||||
factory PlexServerInfo.fromJson(Map<String, dynamic> json) =>
|
||||
_$PlexServerInfoFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$PlexServerInfoToJson(this);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'plex_server_info.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
PlexServerInfo _$PlexServerInfoFromJson(Map<String, dynamic> json) =>
|
||||
PlexServerInfo(
|
||||
name: json['name'] as String,
|
||||
host: json['host'] as String?,
|
||||
port: (json['port'] as num?)?.toInt(),
|
||||
machineIdentifier: json['machineIdentifier'] as String?,
|
||||
version: json['version'] as String,
|
||||
owned: json['owned'] as bool?,
|
||||
https: json['https'] as bool?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$PlexServerInfoToJson(PlexServerInfo instance) =>
|
||||
<String, dynamic>{
|
||||
'name': instance.name,
|
||||
'host': instance.host,
|
||||
'port': instance.port,
|
||||
'machineIdentifier': instance.machineIdentifier,
|
||||
'version': instance.version,
|
||||
'owned': instance.owned,
|
||||
'https': instance.https,
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
class PlexSort {
|
||||
final String key;
|
||||
final String? descKey;
|
||||
final String title;
|
||||
final String? defaultDirection;
|
||||
|
||||
PlexSort({
|
||||
required this.key,
|
||||
this.descKey,
|
||||
required this.title,
|
||||
this.defaultDirection,
|
||||
});
|
||||
|
||||
factory PlexSort.fromJson(Map<String, dynamic> json) {
|
||||
return PlexSort(
|
||||
key: json['key'] as String,
|
||||
descKey: json['descKey'] as String?,
|
||||
title: json['title'] as String,
|
||||
defaultDirection: json['defaultDirection'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
/// Gets the full sort key with direction
|
||||
/// If [descending] is true, returns the descKey or key:desc
|
||||
/// Otherwise returns the key for ascending sort
|
||||
String getSortKey({bool descending = false}) {
|
||||
if (!descending) {
|
||||
return key;
|
||||
}
|
||||
|
||||
// Use descKey if available, otherwise append :desc to key
|
||||
return descKey ?? '$key:desc';
|
||||
}
|
||||
|
||||
/// Returns true if this sort's default direction is descending
|
||||
bool get isDefaultDescending {
|
||||
return defaultDirection?.toLowerCase() == 'desc';
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'PlexSort(key: $key, title: $title, defaultDirection: $defaultDirection)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
return other is PlexSort && other.key == key;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => key.hashCode;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import 'plex_media_info.dart';
|
||||
import 'plex_media_version.dart';
|
||||
|
||||
/// Consolidated data model containing all information needed for video playback.
|
||||
/// This model combines data from multiple Plex API endpoints to reduce redundant requests.
|
||||
class PlexVideoPlaybackData {
|
||||
/// Direct video URL for playback
|
||||
final String? videoUrl;
|
||||
|
||||
/// Media information including audio/subtitle tracks and chapters
|
||||
final PlexMediaInfo? mediaInfo;
|
||||
|
||||
/// Available media versions/qualities for this content
|
||||
final List<PlexMediaVersion> availableVersions;
|
||||
|
||||
PlexVideoPlaybackData({
|
||||
required this.videoUrl,
|
||||
required this.mediaInfo,
|
||||
required this.availableVersions,
|
||||
});
|
||||
|
||||
/// Returns true if this playback data has a valid video URL
|
||||
bool get hasValidVideoUrl => videoUrl != null && videoUrl!.isNotEmpty;
|
||||
|
||||
/// Returns true if media info is available
|
||||
bool get hasMediaInfo => mediaInfo != null;
|
||||
|
||||
/// Returns true if there are multiple media versions available
|
||||
bool get hasMultipleVersions => availableVersions.length > 1;
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../utils/app_logger.dart';
|
||||
|
||||
/// Maintains the list of endpoints we can cycle through when one fails.
|
||||
class EndpointFailoverManager {
|
||||
EndpointFailoverManager(List<String> urls) {
|
||||
_setEndpoints(urls);
|
||||
}
|
||||
|
||||
late List<String> _endpoints;
|
||||
int _currentIndex = 0;
|
||||
|
||||
List<String> get endpoints => List.unmodifiable(_endpoints);
|
||||
|
||||
String get current => _endpoints[_currentIndex];
|
||||
|
||||
bool get hasFallback => _currentIndex < _endpoints.length - 1;
|
||||
|
||||
/// Move to the next endpoint, returning its URL or null if exhausted.
|
||||
String? moveToNext() {
|
||||
if (!hasFallback) return null;
|
||||
_currentIndex++;
|
||||
return _endpoints[_currentIndex];
|
||||
}
|
||||
|
||||
/// Replace the endpoint list and optionally set the active endpoint.
|
||||
void reset(List<String> urls, {String? currentBaseUrl}) {
|
||||
_setEndpoints(urls);
|
||||
if (currentBaseUrl != null) {
|
||||
final index = _endpoints.indexOf(currentBaseUrl);
|
||||
_currentIndex = index >= 0 ? index : 0;
|
||||
} else {
|
||||
_currentIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
void _setEndpoints(List<String> urls) {
|
||||
final sanitized = <String>[];
|
||||
final seen = <String>{};
|
||||
for (final url in urls) {
|
||||
if (url.isEmpty || seen.contains(url)) continue;
|
||||
seen.add(url);
|
||||
sanitized.add(url);
|
||||
}
|
||||
if (sanitized.isEmpty) {
|
||||
throw ArgumentError('At least one endpoint is required');
|
||||
}
|
||||
_endpoints = sanitized;
|
||||
_currentIndex = _currentIndex.clamp(0, _endpoints.length - 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Dio interceptor that retries failed requests on the next available endpoint.
|
||||
class EndpointFailoverInterceptor extends Interceptor {
|
||||
EndpointFailoverInterceptor({
|
||||
required Dio dio,
|
||||
required this.endpointManager,
|
||||
required Future<void> Function(String newBaseUrl) onEndpointSwitch,
|
||||
}) : _dio = dio,
|
||||
_onEndpointSwitch = onEndpointSwitch;
|
||||
|
||||
final Dio _dio;
|
||||
final EndpointFailoverManager endpointManager;
|
||||
final Future<void> Function(String newBaseUrl) _onEndpointSwitch;
|
||||
bool _isSwitching = false;
|
||||
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) async {
|
||||
if (_isSwitching ||
|
||||
!_shouldAttemptFailover(err) ||
|
||||
!endpointManager.hasFallback) {
|
||||
handler.next(err);
|
||||
return;
|
||||
}
|
||||
|
||||
final failedEndpoint = endpointManager.current;
|
||||
appLogger.w(
|
||||
'Endpoint request failed, evaluating failover',
|
||||
error: {
|
||||
'endpoint': failedEndpoint,
|
||||
'type': err.type.name,
|
||||
'statusCode': err.response?.statusCode,
|
||||
},
|
||||
stackTrace: err.stackTrace,
|
||||
);
|
||||
|
||||
final nextBaseUrl = endpointManager.moveToNext();
|
||||
if (nextBaseUrl == null) {
|
||||
appLogger.w(
|
||||
'Endpoint failure but no fallback endpoints remain',
|
||||
error: {'failedEndpoint': failedEndpoint},
|
||||
);
|
||||
handler.next(err);
|
||||
return;
|
||||
}
|
||||
|
||||
_isSwitching = true;
|
||||
try {
|
||||
appLogger.i(
|
||||
'Switching Plex endpoint after request failure',
|
||||
error: {
|
||||
'from': failedEndpoint,
|
||||
'to': nextBaseUrl,
|
||||
'path': err.requestOptions.path,
|
||||
},
|
||||
);
|
||||
await _onEndpointSwitch(nextBaseUrl);
|
||||
final response = await _retryRequest(err.requestOptions);
|
||||
appLogger.i(
|
||||
'Endpoint failover retry succeeded',
|
||||
error: {'newEndpoint': nextBaseUrl},
|
||||
);
|
||||
handler.resolve(response);
|
||||
} on DioException catch (dioError) {
|
||||
appLogger.w(
|
||||
'Endpoint failover retry failed',
|
||||
error: {
|
||||
'newEndpoint': nextBaseUrl,
|
||||
'type': dioError.type.name,
|
||||
'statusCode': dioError.response?.statusCode,
|
||||
},
|
||||
stackTrace: dioError.stackTrace,
|
||||
);
|
||||
handler.next(dioError);
|
||||
} catch (_) {
|
||||
handler.next(err);
|
||||
} finally {
|
||||
_isSwitching = false;
|
||||
}
|
||||
}
|
||||
|
||||
bool _shouldAttemptFailover(DioException error) {
|
||||
if (error.type == DioExceptionType.connectionTimeout ||
|
||||
error.type == DioExceptionType.receiveTimeout ||
|
||||
error.type == DioExceptionType.sendTimeout ||
|
||||
error.type == DioExceptionType.connectionError) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<Response<dynamic>> _retryRequest(RequestOptions requestOptions) {
|
||||
final options = Options(
|
||||
method: requestOptions.method,
|
||||
headers: requestOptions.headers,
|
||||
responseType: requestOptions.responseType,
|
||||
contentType: requestOptions.contentType,
|
||||
followRedirects: requestOptions.followRedirects,
|
||||
receiveDataWhenStatusError: requestOptions.receiveDataWhenStatusError,
|
||||
validateStatus: requestOptions.validateStatus,
|
||||
sendTimeout: requestOptions.sendTimeout,
|
||||
receiveTimeout: requestOptions.receiveTimeout,
|
||||
extra: requestOptions.extra,
|
||||
listFormat: requestOptions.listFormat,
|
||||
);
|
||||
|
||||
return _dio.request<dynamic>(
|
||||
requestOptions.path,
|
||||
data: requestOptions.data,
|
||||
queryParameters: requestOptions.queryParameters,
|
||||
options: options,
|
||||
cancelToken: requestOptions.cancelToken,
|
||||
onSendProgress: requestOptions.onSendProgress,
|
||||
onReceiveProgress: requestOptions.onReceiveProgress,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../services/storage_service.dart';
|
||||
|
||||
/// Provider for managing hidden library state across the app.
|
||||
/// This ensures that when a library is hidden/unhidden in one screen,
|
||||
/// all other screens are automatically updated.
|
||||
class HiddenLibrariesProvider extends ChangeNotifier {
|
||||
late StorageService _storageService;
|
||||
Set<String> _hiddenLibraryKeys = {};
|
||||
bool _isInitialized = false;
|
||||
|
||||
/// Get an unmodifiable copy of hidden library keys
|
||||
Set<String> get hiddenLibraryKeys => Set.unmodifiable(_hiddenLibraryKeys);
|
||||
|
||||
/// Check if the provider has completed initialization
|
||||
bool get isInitialized => _isInitialized;
|
||||
|
||||
HiddenLibrariesProvider() {
|
||||
_initialize();
|
||||
}
|
||||
|
||||
/// Initialize the provider by loading hidden libraries from storage
|
||||
Future<void> _initialize() async {
|
||||
_storageService = await StorageService.getInstance();
|
||||
_hiddenLibraryKeys = _storageService.getHiddenLibraries();
|
||||
_isInitialized = true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Hide a library by its key
|
||||
/// Updates both in-memory state and persistent storage
|
||||
Future<void> hideLibrary(String libraryKey) async {
|
||||
if (!_hiddenLibraryKeys.contains(libraryKey)) {
|
||||
_hiddenLibraryKeys = Set.from(_hiddenLibraryKeys)..add(libraryKey);
|
||||
await _storageService.saveHiddenLibraries(_hiddenLibraryKeys);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Unhide a library by its key
|
||||
/// Updates both in-memory state and persistent storage
|
||||
Future<void> unhideLibrary(String libraryKey) async {
|
||||
if (_hiddenLibraryKeys.contains(libraryKey)) {
|
||||
_hiddenLibraryKeys = Set.from(_hiddenLibraryKeys)..remove(libraryKey);
|
||||
await _storageService.saveHiddenLibraries(_hiddenLibraryKeys);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a specific library is hidden
|
||||
bool isLibraryHidden(String libraryKey) {
|
||||
return _hiddenLibraryKeys.contains(libraryKey);
|
||||
}
|
||||
|
||||
/// Refresh hidden libraries from storage
|
||||
/// Useful if storage was modified outside the provider
|
||||
Future<void> refresh() async {
|
||||
_hiddenLibraryKeys = _storageService.getHiddenLibraries();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../client/plex_client.dart';
|
||||
import '../services/data_aggregation_service.dart';
|
||||
import '../services/multi_server_manager.dart';
|
||||
import '../services/plex_auth_service.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
|
||||
/// Provider for multi-server Plex connections
|
||||
/// Manages multiple PlexClient instances and provides data aggregation
|
||||
class MultiServerProvider extends ChangeNotifier {
|
||||
final MultiServerManager _serverManager;
|
||||
final DataAggregationService _aggregationService;
|
||||
|
||||
MultiServerProvider(this._serverManager, this._aggregationService) {
|
||||
// Listen to server status changes
|
||||
_serverManager.statusStream.listen((_) {
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
|
||||
/// Get the multi-server manager
|
||||
MultiServerManager get serverManager => _serverManager;
|
||||
|
||||
/// Get the data aggregation service
|
||||
DataAggregationService get aggregationService => _aggregationService;
|
||||
|
||||
/// Get client for specific server
|
||||
PlexClient? getClientForServer(String serverId) {
|
||||
return _serverManager.getClient(serverId);
|
||||
}
|
||||
|
||||
/// Get all online server IDs
|
||||
List<String> get onlineServerIds => _serverManager.onlineServerIds;
|
||||
|
||||
/// Get all server IDs
|
||||
List<String> get serverIds => _serverManager.serverIds;
|
||||
|
||||
/// Check if a server is online
|
||||
bool isServerOnline(String serverId) {
|
||||
return _serverManager.isServerOnline(serverId);
|
||||
}
|
||||
|
||||
/// Get number of online servers
|
||||
int get onlineServerCount => _serverManager.onlineServerIds.length;
|
||||
|
||||
/// Get number of total servers
|
||||
int get totalServerCount => _serverManager.serverIds.length;
|
||||
|
||||
/// Check if any servers are connected
|
||||
bool get hasConnectedServers => onlineServerCount > 0;
|
||||
|
||||
/// Update token for a specific server
|
||||
void updateTokenForServer(String serverId, String newToken) {
|
||||
final client = _serverManager.getClient(serverId);
|
||||
if (client != null) {
|
||||
client.updateToken(newToken);
|
||||
appLogger.d('MultiServerProvider: Token updated for server $serverId');
|
||||
notifyListeners();
|
||||
} else {
|
||||
appLogger.w(
|
||||
'MultiServerProvider: Cannot update token - server $serverId not found',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all server connections
|
||||
void clearAllConnections() {
|
||||
_serverManager.disconnectAll();
|
||||
appLogger.d('MultiServerProvider: All connections cleared');
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Reconnect all servers after a profile switch
|
||||
/// Clears existing connections and connects to all provided servers
|
||||
Future<int> reconnectWithServers(
|
||||
List<PlexServer> servers, {
|
||||
String? clientIdentifier,
|
||||
}) async {
|
||||
// Clear existing connections first
|
||||
_serverManager.disconnectAll();
|
||||
appLogger.d(
|
||||
'MultiServerProvider: Cleared connections, reconnecting to ${servers.length} servers',
|
||||
);
|
||||
|
||||
// Connect with new server tokens
|
||||
final connectedCount = await _serverManager.connectToAllServers(
|
||||
servers,
|
||||
clientIdentifier: clientIdentifier,
|
||||
);
|
||||
|
||||
appLogger.i(
|
||||
'MultiServerProvider: Reconnected to $connectedCount/${servers.length} servers after profile switch',
|
||||
);
|
||||
notifyListeners();
|
||||
return connectedCount;
|
||||
}
|
||||
|
||||
/// Check server health for all connected servers
|
||||
Future<void> checkServerHealth() async {
|
||||
await _serverManager.checkServerHealth();
|
||||
// notifyListeners() will be called automatically via status stream
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_serverManager.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../models/play_queue_response.dart';
|
||||
import '../client/plex_client.dart';
|
||||
|
||||
/// Playback mode types
|
||||
///
|
||||
/// All playback now uses Plex play queues.
|
||||
enum PlaybackMode {
|
||||
playQueue, // Play queue-based playback (sequential, shuffle, playlists, collections)
|
||||
}
|
||||
|
||||
/// Result of trying to locate the current queue index.
|
||||
class _IndexLookupResult {
|
||||
final int? index;
|
||||
final bool attemptedLoad;
|
||||
final bool loadFailed;
|
||||
|
||||
const _IndexLookupResult({
|
||||
this.index,
|
||||
this.attemptedLoad = false,
|
||||
this.loadFailed = false,
|
||||
});
|
||||
}
|
||||
|
||||
/// Manages playback state using Plex's play queue API.
|
||||
/// This provider is session-only and does not persist across app restarts.
|
||||
class PlaybackStateProvider with ChangeNotifier {
|
||||
// Play queue state
|
||||
int? _playQueueId;
|
||||
int _playQueueTotalCount = 0;
|
||||
bool _playQueueShuffled = false;
|
||||
int? _currentPlayQueueItemID;
|
||||
|
||||
// Windowed items (loaded around current position)
|
||||
List<PlexMetadata> _loadedItems = [];
|
||||
final int _windowSize = 50; // Number of items to keep in memory
|
||||
|
||||
// Legacy state for backward compatibility
|
||||
String? _contextKey; // The show/season/playlist ratingKey for this session
|
||||
PlaybackMode? _playbackMode;
|
||||
|
||||
// Client reference for loading more items
|
||||
PlexClient? _client;
|
||||
|
||||
/// Current playback mode (null if no queue active)
|
||||
PlaybackMode? get playbackMode => _playbackMode;
|
||||
|
||||
/// Whether shuffle mode is currently active
|
||||
bool get isShuffleActive => _playQueueShuffled;
|
||||
|
||||
/// Whether playlist/collection mode is currently active
|
||||
bool get isPlaylistActive => _playbackMode == PlaybackMode.playQueue;
|
||||
|
||||
/// Whether any queue-based playback is active
|
||||
bool get isQueueActive =>
|
||||
_playQueueId != null && _playbackMode == PlaybackMode.playQueue;
|
||||
|
||||
/// The context key (show/season/playlist ratingKey) for the current session
|
||||
String? get shuffleContextKey => _contextKey;
|
||||
|
||||
/// Current play queue ID
|
||||
int? get playQueueId => _playQueueId;
|
||||
|
||||
/// Total number of items in the play queue
|
||||
int get queueLength => _playQueueTotalCount;
|
||||
|
||||
/// Gets the current position in the queue (1-indexed)
|
||||
int get currentPosition {
|
||||
if (_currentPlayQueueItemID == null || _loadedItems.isEmpty) return 0;
|
||||
final index = _loadedItems.indexWhere(
|
||||
(item) => item.playQueueItemID == _currentPlayQueueItemID,
|
||||
);
|
||||
return index != -1 ? index + 1 : 0;
|
||||
}
|
||||
|
||||
/// Set the client reference for loading more items
|
||||
void setClient(PlexClient client) {
|
||||
_client = client;
|
||||
}
|
||||
|
||||
/// Update the current play queue item when playing a new item
|
||||
void setCurrentItem(PlexMetadata metadata) {
|
||||
if (_playbackMode == PlaybackMode.playQueue &&
|
||||
metadata.playQueueItemID != null) {
|
||||
_currentPlayQueueItemID = metadata.playQueueItemID;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize playback from a play queue
|
||||
/// Call this after creating a play queue via the API
|
||||
Future<void> setPlaybackFromPlayQueue(
|
||||
PlayQueueResponse playQueue,
|
||||
String? contextKey, {
|
||||
String? serverId,
|
||||
String? serverName,
|
||||
}) async {
|
||||
_playQueueId = playQueue.playQueueID;
|
||||
// Use size or items length as fallback if totalCount is null
|
||||
_playQueueTotalCount =
|
||||
playQueue.playQueueTotalCount ??
|
||||
playQueue.size ??
|
||||
(playQueue.items?.length ?? 0);
|
||||
_playQueueShuffled = playQueue.playQueueShuffled;
|
||||
_currentPlayQueueItemID = playQueue.playQueueSelectedItemID;
|
||||
|
||||
// Items are already tagged with server info by PlexClient
|
||||
_loadedItems = playQueue.items ?? [];
|
||||
|
||||
_contextKey = contextKey;
|
||||
_playbackMode = PlaybackMode.playQueue;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Load more items from the play queue if needed
|
||||
/// Returns true if more items were loaded
|
||||
Future<bool> _ensureItemsLoaded(int targetPlayQueueItemID) async {
|
||||
if (_client == null || _playQueueId == null) return false;
|
||||
|
||||
// Check if the target item is already loaded
|
||||
final hasItem = _loadedItems.any(
|
||||
(item) => item.playQueueItemID == targetPlayQueueItemID,
|
||||
);
|
||||
|
||||
if (hasItem) return true;
|
||||
|
||||
// Load a window around the target item
|
||||
try {
|
||||
final response = await _client!.getPlayQueue(
|
||||
_playQueueId!,
|
||||
center: targetPlayQueueItemID.toString(),
|
||||
window: _windowSize,
|
||||
);
|
||||
|
||||
if (response != null && response.items != null) {
|
||||
// Items are already tagged with server info by PlexClient
|
||||
_loadedItems = response.items!;
|
||||
// Use size or items length as fallback if totalCount is null
|
||||
_playQueueTotalCount =
|
||||
response.playQueueTotalCount ??
|
||||
response.size ??
|
||||
response.items!.length;
|
||||
_playQueueShuffled = response.playQueueShuffled;
|
||||
notifyListeners();
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
// Failed to load items
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<_IndexLookupResult> _getCurrentIndex({
|
||||
bool loadIfMissing = false,
|
||||
}) async {
|
||||
if (_playbackMode != PlaybackMode.playQueue ||
|
||||
_loadedItems.isEmpty ||
|
||||
_currentPlayQueueItemID == null) {
|
||||
return const _IndexLookupResult();
|
||||
}
|
||||
|
||||
var currentIndex = _loadedItems.indexWhere(
|
||||
(item) => item.playQueueItemID == _currentPlayQueueItemID,
|
||||
);
|
||||
|
||||
if (currentIndex != -1) {
|
||||
return _IndexLookupResult(index: currentIndex);
|
||||
}
|
||||
|
||||
if (!loadIfMissing || _client == null || _playQueueId == null) {
|
||||
return const _IndexLookupResult();
|
||||
}
|
||||
|
||||
final loaded = await _ensureItemsLoaded(_currentPlayQueueItemID!);
|
||||
if (!loaded) {
|
||||
return const _IndexLookupResult(attemptedLoad: true, loadFailed: true);
|
||||
}
|
||||
|
||||
currentIndex = _loadedItems.indexWhere(
|
||||
(item) => item.playQueueItemID == _currentPlayQueueItemID,
|
||||
);
|
||||
|
||||
if (currentIndex == -1) {
|
||||
return const _IndexLookupResult(attemptedLoad: true, loadFailed: true);
|
||||
}
|
||||
|
||||
return _IndexLookupResult(index: currentIndex, attemptedLoad: true);
|
||||
}
|
||||
|
||||
/// Gets the next item in the playback queue.
|
||||
/// Returns null if queue is exhausted or current item is not in queue.
|
||||
/// [loopQueue] - If true, restart from beginning when queue is exhausted
|
||||
Future<PlexMetadata?> getNextEpisode(
|
||||
String currentItemKey, {
|
||||
bool loopQueue = false,
|
||||
}) async {
|
||||
if (_playbackMode != PlaybackMode.playQueue) {
|
||||
// For sequential mode, let the video player handle next episode
|
||||
return null;
|
||||
}
|
||||
|
||||
final indexResult = await _getCurrentIndex(loadIfMissing: true);
|
||||
if (indexResult.index == null) {
|
||||
if (indexResult.loadFailed) {
|
||||
clearShuffle();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
final currentIndex = indexResult.index!;
|
||||
|
||||
// Check if there's a next item in the loaded window
|
||||
if (currentIndex + 1 < _loadedItems.length) {
|
||||
final nextItem = _loadedItems[currentIndex + 1];
|
||||
// Don't update _currentPlayQueueItemID here - let setCurrentItem do it when playback starts
|
||||
return nextItem;
|
||||
}
|
||||
|
||||
// Check if we're at the end of the entire queue
|
||||
if (currentIndex + 1 >= _playQueueTotalCount) {
|
||||
if (loopQueue && _playQueueTotalCount > 0) {
|
||||
// Loop back to beginning - load first item
|
||||
if (_client != null && _playQueueId != null) {
|
||||
final response = await _client!.getPlayQueue(_playQueueId!);
|
||||
if (response != null &&
|
||||
response.items != null &&
|
||||
response.items!.isNotEmpty) {
|
||||
// Items are already tagged with server info by PlexClient
|
||||
_loadedItems = response.items!;
|
||||
final firstItem = _loadedItems.first;
|
||||
// Don't update _currentPlayQueueItemID here - let setCurrentItem do it when playback starts
|
||||
return firstItem;
|
||||
}
|
||||
}
|
||||
}
|
||||
// At end of queue - return null but keep queue active so user can still go back
|
||||
return null;
|
||||
}
|
||||
|
||||
// Need to load next window
|
||||
if (_client != null && _playQueueId != null) {
|
||||
// Load next window centered on the item after current
|
||||
final nextItemID = _loadedItems.last.playQueueItemID;
|
||||
if (nextItemID != null) {
|
||||
final loaded = await _ensureItemsLoaded(nextItemID + 1);
|
||||
if (loaded) {
|
||||
// Try again with newly loaded items
|
||||
return getNextEpisode(currentItemKey, loopQueue: loopQueue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Gets the previous item in the playback queue.
|
||||
/// Returns null if at the beginning of the queue or current item is not in queue.
|
||||
Future<PlexMetadata?> getPreviousEpisode(String currentItemKey) async {
|
||||
if (_playbackMode != PlaybackMode.playQueue) {
|
||||
// For sequential mode, let the video player handle previous episode
|
||||
return null;
|
||||
}
|
||||
|
||||
final currentIndex = (await _getCurrentIndex()).index;
|
||||
if (currentIndex == null) return null;
|
||||
|
||||
// Check if there's a previous item in the loaded window
|
||||
if (currentIndex > 0) {
|
||||
final prevItem = _loadedItems[currentIndex - 1];
|
||||
// Don't update _currentPlayQueueItemID here - let setCurrentItem do it when playback starts
|
||||
return prevItem;
|
||||
}
|
||||
|
||||
// Check if we're at the beginning of the entire queue
|
||||
if (currentIndex == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Need to load previous window
|
||||
if (_client != null && _playQueueId != null) {
|
||||
final prevItemID = _loadedItems.first.playQueueItemID;
|
||||
if (prevItemID != null && prevItemID > 0) {
|
||||
final loaded = await _ensureItemsLoaded(prevItemID - 1);
|
||||
if (loaded) {
|
||||
return getPreviousEpisode(currentItemKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Clears the playback queue and exits queue mode
|
||||
void clearShuffle() {
|
||||
_playQueueId = null;
|
||||
_playQueueTotalCount = 0;
|
||||
_playQueueShuffled = false;
|
||||
_currentPlayQueueItemID = null;
|
||||
_loadedItems = [];
|
||||
_contextKey = null;
|
||||
_playbackMode = null;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Provider for tracking server-specific UI state
|
||||
/// Manages which server is currently in context for detail views
|
||||
class ServerStateProvider extends ChangeNotifier {
|
||||
String? _currentServerId;
|
||||
final Map<String, bool> _serverConnectionStates = {};
|
||||
final Map<String, String?> _serverErrors = {};
|
||||
|
||||
/// Get the currently selected server ID (for detail views)
|
||||
String? get currentServerId => _currentServerId;
|
||||
|
||||
/// Set the current server context (e.g., when viewing a library from a specific server)
|
||||
void setCurrentServer(String? serverId) {
|
||||
if (_currentServerId != serverId) {
|
||||
_currentServerId = serverId;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the current server selection
|
||||
void clearCurrentServer() {
|
||||
if (_currentServerId != null) {
|
||||
_currentServerId = null;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Get connection state for a server
|
||||
bool isServerConnected(String serverId) {
|
||||
return _serverConnectionStates[serverId] ?? false;
|
||||
}
|
||||
|
||||
/// Update connection state for a server
|
||||
void updateServerConnectionState(String serverId, bool isConnected) {
|
||||
if (_serverConnectionStates[serverId] != isConnected) {
|
||||
_serverConnectionStates[serverId] = isConnected;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Get error message for a server (null if no error)
|
||||
String? getServerError(String serverId) {
|
||||
return _serverErrors[serverId];
|
||||
}
|
||||
|
||||
/// Set error for a server
|
||||
void setServerError(String serverId, String? error) {
|
||||
_serverErrors[serverId] = error;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Clear error for a server
|
||||
void clearServerError(String serverId) {
|
||||
if (_serverErrors.containsKey(serverId)) {
|
||||
_serverErrors.remove(serverId);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all server errors
|
||||
void clearAllServerErrors() {
|
||||
if (_serverErrors.isNotEmpty) {
|
||||
_serverErrors.clear();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset all state
|
||||
void reset() {
|
||||
_currentServerId = null;
|
||||
_serverConnectionStates.clear();
|
||||
_serverErrors.clear();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/settings_service.dart';
|
||||
|
||||
class SettingsProvider extends ChangeNotifier {
|
||||
late SettingsService _settingsService;
|
||||
LibraryDensity _libraryDensity = LibraryDensity.normal;
|
||||
ViewMode _viewMode = ViewMode.grid;
|
||||
bool _useSeasonPoster = false;
|
||||
bool _showHeroSection = true;
|
||||
|
||||
SettingsProvider() {
|
||||
_initializeSettings();
|
||||
}
|
||||
|
||||
Future<void> _initializeSettings() async {
|
||||
_settingsService = await SettingsService.getInstance();
|
||||
_libraryDensity = _settingsService.getLibraryDensity();
|
||||
_viewMode = _settingsService.getViewMode();
|
||||
_useSeasonPoster = _settingsService.getUseSeasonPoster();
|
||||
_showHeroSection = _settingsService.getShowHeroSection();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
LibraryDensity get libraryDensity => _libraryDensity;
|
||||
ViewMode get viewMode => _viewMode;
|
||||
bool get useSeasonPoster => _useSeasonPoster;
|
||||
bool get showHeroSection => _showHeroSection;
|
||||
|
||||
Future<void> setLibraryDensity(LibraryDensity density) async {
|
||||
if (_libraryDensity != density) {
|
||||
_libraryDensity = density;
|
||||
await _settingsService.setLibraryDensity(density);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setViewMode(ViewMode mode) async {
|
||||
if (_viewMode != mode) {
|
||||
_viewMode = mode;
|
||||
await _settingsService.setViewMode(mode);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setUseSeasonPoster(bool value) async {
|
||||
if (_useSeasonPoster != value) {
|
||||
_useSeasonPoster = value;
|
||||
await _settingsService.setUseSeasonPoster(value);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setShowHeroSection(bool value) async {
|
||||
if (_showHeroSection != value) {
|
||||
_showHeroSection = value;
|
||||
await _settingsService.setShowHeroSection(value);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
String get libraryDensityDisplayName {
|
||||
switch (_libraryDensity) {
|
||||
case LibraryDensity.compact:
|
||||
return 'Compact';
|
||||
case LibraryDensity.normal:
|
||||
return 'Normal';
|
||||
case LibraryDensity.comfortable:
|
||||
return 'Comfortable';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/plex_home.dart';
|
||||
import '../models/plex_home_user.dart';
|
||||
@@ -6,6 +7,7 @@ import '../services/plex_auth_service.dart';
|
||||
import '../services/storage_service.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
import '../widgets/pin_entry_dialog.dart';
|
||||
import 'plex_client_provider.dart';
|
||||
|
||||
class UserProfileProvider extends ChangeNotifier {
|
||||
@@ -32,18 +34,24 @@ class UserProfileProvider extends ChangeNotifier {
|
||||
StorageService? _storageService;
|
||||
|
||||
// Callback for data invalidation when switching profiles
|
||||
VoidCallback? _onDataInvalidationRequested;
|
||||
// Receives the list of servers with new profile tokens for reconnection
|
||||
Future<void> Function(List<PlexServer>)? _onDataInvalidationRequested;
|
||||
|
||||
/// Set a callback to be called when profile switching requires data invalidation
|
||||
void setDataInvalidationCallback(VoidCallback? callback) {
|
||||
/// The callback receives the list of servers with the new profile's access tokens
|
||||
void setDataInvalidationCallback(
|
||||
Future<void> Function(List<PlexServer>)? callback,
|
||||
) {
|
||||
_onDataInvalidationRequested = callback;
|
||||
}
|
||||
|
||||
/// Trigger data invalidation for all screens
|
||||
void _invalidateAllData() {
|
||||
/// Trigger data invalidation for all screens with the new profile's servers
|
||||
Future<void> _invalidateAllData(List<PlexServer> servers) async {
|
||||
if (_onDataInvalidationRequested != null) {
|
||||
_onDataInvalidationRequested!();
|
||||
appLogger.d('Data invalidation triggered for profile switch');
|
||||
await _onDataInvalidationRequested!(servers);
|
||||
appLogger.d(
|
||||
'Data invalidation triggered for profile switch with ${servers.length} servers',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,19 +262,45 @@ class UserProfileProvider extends ChangeNotifier {
|
||||
_setLoading(true);
|
||||
_clearError();
|
||||
|
||||
return await _attemptUserSwitch(user, context, clientProvider, null);
|
||||
}
|
||||
|
||||
Future<bool> _attemptUserSwitch(
|
||||
PlexHomeUser user,
|
||||
BuildContext? context,
|
||||
PlexClientProvider? clientProvider,
|
||||
String? errorMessage,
|
||||
) async {
|
||||
try {
|
||||
final currentToken = _storageService!.getPlexToken();
|
||||
if (currentToken == null) {
|
||||
throw Exception('No Plex.tv authentication token available');
|
||||
}
|
||||
|
||||
// Check if user requires PIN
|
||||
String? pin;
|
||||
if (user.requiresPassword && context != null && context.mounted) {
|
||||
pin = await showPinEntryDialog(
|
||||
context,
|
||||
user.displayName,
|
||||
errorMessage: errorMessage,
|
||||
);
|
||||
|
||||
// User cancelled the PIN dialog
|
||||
if (pin == null) {
|
||||
_setLoading(false);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
final switchResponse = await _authService!.switchToUser(
|
||||
user.uuid,
|
||||
currentToken,
|
||||
pin: pin,
|
||||
);
|
||||
|
||||
// switchResponse.authToken is the new user's Plex.tv token
|
||||
// We need to fetch servers with this token to get the proper server access token
|
||||
// Fetch servers with this token to get the proper server access tokens
|
||||
appLogger.d('Got new user Plex.tv token, fetching servers...');
|
||||
|
||||
final servers = await _authService!.fetchServers(
|
||||
@@ -276,38 +310,14 @@ class UserProfileProvider extends ChangeNotifier {
|
||||
throw Exception('No servers available for this user');
|
||||
}
|
||||
|
||||
// Find the current server from storage
|
||||
final currentServerData = _storageService!.getServerData();
|
||||
if (currentServerData == null) {
|
||||
throw Exception('No current server data found');
|
||||
}
|
||||
appLogger.d('Fetched ${servers.length} servers for new profile');
|
||||
|
||||
// Find matching server by name or client identifier
|
||||
final currentServerName = currentServerData['name'] as String?;
|
||||
final currentServerClientId =
|
||||
currentServerData['clientIdentifier'] as String?;
|
||||
|
||||
final matchingServer = servers.firstWhere(
|
||||
(server) =>
|
||||
server.name == currentServerName ||
|
||||
server.clientIdentifier == currentServerClientId,
|
||||
orElse: () => servers.first, // Fallback to first server
|
||||
);
|
||||
|
||||
appLogger.d(
|
||||
'Found matching server: ${matchingServer.name}, getting access token',
|
||||
);
|
||||
|
||||
// Update storage with new tokens
|
||||
await _storageService!.updateCurrentUser(
|
||||
user.uuid,
|
||||
matchingServer
|
||||
.accessToken, // Use server access token, not Plex.tv token
|
||||
);
|
||||
|
||||
// Also save the new Plex.tv token for future profile operations
|
||||
// Save the new Plex.tv token for future profile operations
|
||||
await _storageService!.savePlexToken(switchResponse.authToken);
|
||||
|
||||
// Update current user UUID in storage
|
||||
await _storageService!.saveCurrentUserUUID(user.uuid);
|
||||
|
||||
// Update current user
|
||||
_currentUser = user;
|
||||
|
||||
@@ -323,31 +333,49 @@ class UserProfileProvider extends ChangeNotifier {
|
||||
},
|
||||
);
|
||||
|
||||
// Update PlexClient with proper server access token
|
||||
if (clientProvider != null) {
|
||||
try {
|
||||
clientProvider.updateToken(matchingServer.accessToken);
|
||||
appLogger.d(
|
||||
'Updated PlexClient with server access token for user: ${user.displayName}',
|
||||
);
|
||||
} catch (e) {
|
||||
appLogger.w('Failed to update PlexClient token', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
|
||||
// Invalidate all cached data for the new profile
|
||||
// This will cause screens to refresh and rebuild widgets with the new token
|
||||
_invalidateAllData();
|
||||
// Invalidate all cached data and reconnect to all servers with new tokens
|
||||
// The callback will handle server reconnection using the servers list
|
||||
await _invalidateAllData(servers);
|
||||
|
||||
appLogger.d(
|
||||
'Profile switch complete, all data and images should refresh with new token',
|
||||
'Profile switch complete, all servers reconnected with new tokens',
|
||||
);
|
||||
|
||||
appLogger.i('Successfully switched to user: ${user.displayName}');
|
||||
return true;
|
||||
} catch (e) {
|
||||
// Check if it's a PIN validation error
|
||||
if (e is DioException && e.response?.statusCode == 403) {
|
||||
final errors = e.response?.data['errors'] as List?;
|
||||
if (errors != null && errors.isNotEmpty) {
|
||||
final errorCode = errors[0]['code'] as int?;
|
||||
final errorMessage = errors[0]['message'] as String?;
|
||||
|
||||
// Error code 1041 means invalid PIN
|
||||
if (errorCode == 1041) {
|
||||
appLogger.w('Invalid PIN for user: ${user.displayName}');
|
||||
_clearError(); // Clear any previous error state
|
||||
|
||||
// Retry with error message if context is still available
|
||||
if (context != null && context.mounted) {
|
||||
return await _attemptUserSwitch(
|
||||
user,
|
||||
context,
|
||||
clientProvider,
|
||||
errorMessage ?? 'Incorrect PIN. Please try again.',
|
||||
);
|
||||
}
|
||||
|
||||
// If context not available, return false without showing error
|
||||
appLogger.d('Cannot retry PIN entry - context not available');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only show error for non-PIN validation errors
|
||||
_setError('Failed to switch user: $e');
|
||||
appLogger.e('Failed to switch to user: ${user.displayName}', error: e);
|
||||
return false;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import '../widgets/desktop_app_bar.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import 'licenses_screen.dart';
|
||||
|
||||
class AboutScreen extends StatefulWidget {
|
||||
@@ -23,7 +24,7 @@ class _AboutScreenState extends State<AboutScreen> {
|
||||
Future<void> _loadPackageInfo() async {
|
||||
final packageInfo = await PackageInfo.fromPlatform();
|
||||
setState(() {
|
||||
_appName = 'Plezy';
|
||||
_appName = t.app.title;
|
||||
_appVersion = packageInfo.version;
|
||||
});
|
||||
}
|
||||
@@ -36,7 +37,7 @@ class _AboutScreenState extends State<AboutScreen> {
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
CustomAppBar(title: const Text('About'), pinned: true),
|
||||
CustomAppBar(title: Text(t.about.title), pinned: true),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
sliver: SliverList(
|
||||
@@ -55,14 +56,14 @@ class _AboutScreenState extends State<AboutScreen> {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Version $appVersion',
|
||||
t.about.versionLabel(version: appVersion),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'A beautiful Plex client for Flutter',
|
||||
t.about.appDescription,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
@@ -76,10 +77,8 @@ class _AboutScreenState extends State<AboutScreen> {
|
||||
Card(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.description),
|
||||
title: const Text('Open Source Licenses'),
|
||||
subtitle: const Text(
|
||||
'View licenses of third-party libraries',
|
||||
),
|
||||
title: Text(t.about.openSourceLicenses),
|
||||
subtitle: Text(t.about.viewLicensesDescription),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
import '../services/plex_auth_service.dart';
|
||||
import '../services/storage_service.dart';
|
||||
import 'server_selection_screen.dart';
|
||||
import '../services/server_registry.dart';
|
||||
import '../providers/multi_server_provider.dart';
|
||||
import '../providers/plex_client_provider.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import 'main_screen.dart';
|
||||
|
||||
class AuthScreen extends StatefulWidget {
|
||||
const AuthScreen({super.key});
|
||||
@@ -18,6 +25,8 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
String? _errorMessage;
|
||||
late PlexAuthService _authService;
|
||||
bool _shouldCancelPolling = false;
|
||||
bool _useQrFlow = false; // whether current auth attempt is QR based
|
||||
String? _qrAuthUrl; // auth URL rendered as QR
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -29,11 +38,85 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
_authService = await PlexAuthService.create();
|
||||
}
|
||||
|
||||
/// Connect to all available servers and navigate to main screen
|
||||
Future<void> _connectToAllServersAndNavigate(String plexToken) async {
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_isAuthenticating = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
// Fetch all servers for this user
|
||||
final servers = await _authService.fetchServers(plexToken);
|
||||
|
||||
if (servers.isEmpty) {
|
||||
setState(() {
|
||||
_isAuthenticating = false;
|
||||
_errorMessage = t.serverSelection.noServersFound;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Save all servers to registry and enable them
|
||||
final storage = await StorageService.getInstance();
|
||||
final registry = ServerRegistry(storage);
|
||||
await registry.saveServers(servers);
|
||||
|
||||
// Enable all servers
|
||||
final serverIds = servers.map((s) => s.clientIdentifier).toSet();
|
||||
await registry.saveEnabledServerIds(serverIds);
|
||||
|
||||
// Connect to all servers
|
||||
if (!mounted) return;
|
||||
final multiServerProvider = context.read<MultiServerProvider>();
|
||||
final connectedCount = await multiServerProvider.serverManager
|
||||
.connectToAllServers(servers);
|
||||
|
||||
if (connectedCount == 0) {
|
||||
setState(() {
|
||||
_isAuthenticating = false;
|
||||
_errorMessage = t.serverSelection.allServerConnectionsFailed;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the first connected client for backward compatibility
|
||||
if (!mounted) return;
|
||||
final firstClient =
|
||||
multiServerProvider.serverManager.onlineClients.values.first;
|
||||
|
||||
// Set it as the legacy client
|
||||
final plexClientProvider = context.read<PlexClientProvider>();
|
||||
plexClientProvider.setClient(firstClient);
|
||||
|
||||
// Navigate to main screen
|
||||
if (!mounted) return;
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => MainScreen(client: firstClient),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to connect to servers', error: e);
|
||||
setState(() {
|
||||
_isAuthenticating = false;
|
||||
_errorMessage = t.serverSelection.failedToLoadServers(error: e);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _startAuthentication() async {
|
||||
setState(() {
|
||||
_isAuthenticating = true;
|
||||
_errorMessage = null;
|
||||
_shouldCancelPolling = false;
|
||||
// preserve _useQrFlow as chosen prior to calling
|
||||
if (!_useQrFlow) {
|
||||
_qrAuthUrl = null; // ensure stale QR cleared for browser flow
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -45,12 +128,19 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
// Construct auth URL
|
||||
final authUrl = _authService.getAuthUrl(pinCode);
|
||||
|
||||
// Open browser (in-app for mobile, external for desktop)
|
||||
final uri = Uri.parse(authUrl);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.inAppBrowserView);
|
||||
if (_useQrFlow) {
|
||||
// Display QR instead of launching browser
|
||||
setState(() {
|
||||
_qrAuthUrl = authUrl;
|
||||
});
|
||||
} else {
|
||||
throw Exception('Could not launch auth URL');
|
||||
// Open browser (in-app for mobile, external for desktop)
|
||||
final uri = Uri.parse(authUrl);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.inAppBrowserView);
|
||||
} else {
|
||||
throw Exception(t.errors.couldNotLaunchUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// Poll for authentication with cancellation support
|
||||
@@ -67,38 +157,38 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
if (token == null) {
|
||||
setState(() {
|
||||
_isAuthenticating = false;
|
||||
_errorMessage = 'Authentication timed out. Please try again.';
|
||||
_errorMessage = t.auth.authenticationTimeout;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-close the in-app browser on mobile (no-op on desktop)
|
||||
try {
|
||||
await closeInAppWebView();
|
||||
} catch (e) {
|
||||
// Ignore errors - browser might already be closed or on desktop
|
||||
if (!_useQrFlow) {
|
||||
try {
|
||||
await closeInAppWebView();
|
||||
} catch (e) {
|
||||
// Ignore errors - browser might already be closed or on desktop
|
||||
}
|
||||
}
|
||||
|
||||
// Store the token
|
||||
final storage = await StorageService.getInstance();
|
||||
await storage.savePlexToken(token);
|
||||
|
||||
// Navigate to server selection
|
||||
// Clear QR URL after successful auth
|
||||
setState(() {
|
||||
_qrAuthUrl = null;
|
||||
_useQrFlow = false;
|
||||
});
|
||||
|
||||
// Connect to all servers and navigate to main screen
|
||||
if (mounted) {
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ServerSelectionScreen(
|
||||
authService: _authService,
|
||||
plexToken: token,
|
||||
),
|
||||
),
|
||||
);
|
||||
await _connectToAllServersAndNavigate(token);
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_isAuthenticating = false;
|
||||
_errorMessage = 'Authentication failed: $e';
|
||||
_errorMessage = t.errors.authenticationFailed(error: e);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -107,6 +197,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
setState(() {
|
||||
_shouldCancelPolling = true;
|
||||
_isAuthenticating = false;
|
||||
_qrAuthUrl = null;
|
||||
});
|
||||
// Start new authentication after a brief delay to ensure cleanup
|
||||
Future.delayed(const Duration(milliseconds: 100), _startAuthentication);
|
||||
@@ -127,15 +218,15 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setDialogState) {
|
||||
return AlertDialog(
|
||||
title: const Text('Debug: Enter Plex Token'),
|
||||
title: Text(t.auth.debugEnterToken),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: tokenController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Plex Auth Token',
|
||||
hintText: 'Enter your Plex.tv token',
|
||||
labelText: t.auth.plexTokenLabel,
|
||||
hintText: t.auth.plexTokenHint,
|
||||
errorText: errorMessage,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
@@ -147,14 +238,14 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
child: Text(t.auth.cancel),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
final token = tokenController.text.trim();
|
||||
if (token.isEmpty) {
|
||||
setDialogState(() {
|
||||
errorMessage = 'Please enter a token';
|
||||
errorMessage = t.errors.pleaseEnterToken;
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -165,7 +256,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
final isValid = await _authService.verifyToken(token);
|
||||
if (!isValid) {
|
||||
setDialogState(() {
|
||||
errorMessage = 'Invalid token';
|
||||
errorMessage = t.errors.invalidToken;
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -174,25 +265,18 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
final storage = await StorageService.getInstance();
|
||||
await storage.savePlexToken(token);
|
||||
|
||||
// Close dialog and navigate
|
||||
// Close dialog and connect to all servers
|
||||
if (mounted) {
|
||||
navigator.pop();
|
||||
navigator.pushReplacement(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ServerSelectionScreen(
|
||||
authService: _authService,
|
||||
plexToken: token,
|
||||
),
|
||||
),
|
||||
);
|
||||
await _connectToAllServersAndNavigate(token);
|
||||
}
|
||||
} catch (e) {
|
||||
setDialogState(() {
|
||||
errorMessage = 'Failed to verify token: $e';
|
||||
errorMessage = t.errors.failedToVerifyToken(error: e);
|
||||
});
|
||||
}
|
||||
},
|
||||
child: const Text('Authenticate'),
|
||||
child: Text(t.auth.authenticate),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -204,83 +288,244 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Use two-column layout on desktop, single column on mobile
|
||||
final isDesktop = MediaQuery.of(context).size.width > 700;
|
||||
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
constraints: BoxConstraints(maxWidth: isDesktop ? 800 : 400),
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Image.asset('assets/plezy.png', width: 120, height: 120),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Plezy',
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
if (_isAuthenticating) ...[
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Waiting for authentication...\nPlease complete sign-in in your browser.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
OutlinedButton(
|
||||
onPressed: _retryAuthentication,
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 12,
|
||||
horizontal: 24,
|
||||
),
|
||||
),
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
] else ...[
|
||||
ElevatedButton(
|
||||
onPressed: _startAuthentication,
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
child: const Text('Sign in with Plex'),
|
||||
),
|
||||
if (kDebugMode) ...[
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton(
|
||||
onPressed: _handleDebugTap,
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
side: BorderSide(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.outline.withValues(alpha: 0.5),
|
||||
child: isDesktop
|
||||
? Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// First column - Logo and title (always visible)
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/plezy.png',
|
||||
width: 120,
|
||||
height: 120,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
t.app.title,
|
||||
style: Theme.of(context).textTheme.headlineMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'Debug: Enter Token',
|
||||
style: TextStyle(fontSize: 12),
|
||||
const SizedBox(width: 48),
|
||||
// Second column - All authentication content
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (_isAuthenticating) ...[
|
||||
if (_useQrFlow && _qrAuthUrl != null) ...[
|
||||
// QR code flow - hint text above QR code
|
||||
Text(
|
||||
t.auth.scanQRCodeInstruction,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Center(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: QrImageView(
|
||||
data: _qrAuthUrl!,
|
||||
size: 300,
|
||||
version: QrVersions.auto,
|
||||
backgroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
OutlinedButton(
|
||||
onPressed: _retryAuthentication,
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 12,
|
||||
horizontal: 24,
|
||||
),
|
||||
),
|
||||
child: Text(t.auth.retry),
|
||||
),
|
||||
] else ...[
|
||||
// Browser auth flow - show spinner and waiting message
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
t.auth.waitingForAuth,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
OutlinedButton(
|
||||
onPressed: _retryAuthentication,
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 12,
|
||||
horizontal: 24,
|
||||
),
|
||||
),
|
||||
child: Text(t.auth.retry),
|
||||
),
|
||||
],
|
||||
] else ...[
|
||||
// Initial state buttons
|
||||
ElevatedButton(
|
||||
onPressed: _startAuthentication,
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 16,
|
||||
),
|
||||
),
|
||||
child: Text(t.auth.signInWithPlex),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_useQrFlow = true;
|
||||
});
|
||||
_startAuthentication();
|
||||
},
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 16,
|
||||
),
|
||||
),
|
||||
child: Text(t.auth.showQRCode),
|
||||
),
|
||||
if (kDebugMode) ...[
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton(
|
||||
onPressed: _handleDebugTap,
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 12,
|
||||
),
|
||||
side: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outline
|
||||
.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
t.auth.debugEnterToken,
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (_errorMessage != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_errorMessage!,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (_errorMessage != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_errorMessage!,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
],
|
||||
)
|
||||
: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Image.asset('assets/plezy.png', width: 120, height: 120),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
t.app.title,
|
||||
style: Theme.of(context).textTheme.headlineMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
if (_isAuthenticating) ...[
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
t.auth.waitingForAuth,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
OutlinedButton(
|
||||
onPressed: _retryAuthentication,
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 12,
|
||||
horizontal: 24,
|
||||
),
|
||||
),
|
||||
child: Text(t.auth.retry),
|
||||
),
|
||||
] else ...[
|
||||
// add QR button here
|
||||
ElevatedButton(
|
||||
onPressed: _startAuthentication,
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
child: Text(t.auth.signInWithPlex),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_useQrFlow = true;
|
||||
});
|
||||
_startAuthentication();
|
||||
},
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
child: Text(t.auth.showQRCode),
|
||||
),
|
||||
if (kDebugMode) ...[
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton(
|
||||
onPressed: _handleDebugTap,
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
side: BorderSide(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.outline.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
t.auth.debugEnterToken,
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (_errorMessage != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_errorMessage!,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../client/plex_client.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../providers/multi_server_provider.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
import '../utils/collection_playlist_play_helper.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../mixins/refreshable.dart';
|
||||
import '../mixins/item_updatable.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
|
||||
/// Abstract base class for screens displaying media lists (collections/playlists)
|
||||
/// Provides common state management and playback functionality
|
||||
abstract class BaseMediaListDetailScreen<T extends StatefulWidget>
|
||||
extends State<T>
|
||||
with Refreshable, ItemUpdatable {
|
||||
// State properties - concrete implementations to avoid duplication
|
||||
List<PlexMetadata> items = [];
|
||||
bool isLoading = false;
|
||||
String? errorMessage;
|
||||
|
||||
@override
|
||||
PlexClient get client => _getClientForMediaItem();
|
||||
|
||||
/// The media item being displayed (collection or playlist)
|
||||
dynamic get mediaItem;
|
||||
|
||||
/// Title to display in app bar
|
||||
String get title;
|
||||
|
||||
/// Message to show when list is empty
|
||||
String get emptyMessage;
|
||||
|
||||
/// Optional icon to show when list is empty
|
||||
IconData? get emptyIcon => null;
|
||||
|
||||
/// Get the correct PlexClient for this media item's server
|
||||
PlexClient _getClientForMediaItem() {
|
||||
// Try to get serverId from the media item
|
||||
String? serverId;
|
||||
|
||||
// Check if mediaItem has serverId property
|
||||
if (mediaItem is PlexMetadata) {
|
||||
serverId = (mediaItem as PlexMetadata).serverId;
|
||||
} else if (mediaItem != null) {
|
||||
// For playlists or other types, use dynamic access
|
||||
try {
|
||||
final dynamic item = mediaItem;
|
||||
serverId = item.serverId as String?;
|
||||
} catch (_) {
|
||||
// Ignore if serverId is not available
|
||||
}
|
||||
}
|
||||
|
||||
// If serverId is null, fall back to first available server
|
||||
if (serverId == null) {
|
||||
final multiServerProvider = Provider.of<MultiServerProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
if (!multiServerProvider.hasConnectedServers) {
|
||||
throw Exception(t.errors.noClientAvailable);
|
||||
}
|
||||
serverId = multiServerProvider.onlineServerIds.first;
|
||||
}
|
||||
|
||||
return context.getClientForServer(serverId);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
loadItems();
|
||||
}
|
||||
|
||||
/// Load or reload the items (subclasses implement this)
|
||||
Future<void> loadItems();
|
||||
|
||||
/// Play all items in the list
|
||||
Future<void> playItems() => _playWithShuffle(false);
|
||||
|
||||
/// Shuffle play all items in the list
|
||||
Future<void> shufflePlayItems() => _playWithShuffle(true);
|
||||
|
||||
/// Internal helper to play items with optional shuffle
|
||||
Future<void> _playWithShuffle(bool shuffle) async {
|
||||
if (items.isEmpty) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(emptyMessage)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final client = _getClientForMediaItem();
|
||||
|
||||
await playCollectionOrPlaylist(
|
||||
context: context,
|
||||
client: client,
|
||||
item: mediaItem,
|
||||
shuffle: shuffle,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
final index = items.indexWhere((item) => item.ratingKey == ratingKey);
|
||||
if (index != -1) {
|
||||
items[index] = updatedMetadata;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void refresh() {
|
||||
loadItems();
|
||||
}
|
||||
|
||||
/// Build common error/loading/empty state slivers
|
||||
/// Returns a list of slivers to display based on current state
|
||||
List<Widget> buildStateSlivers() {
|
||||
if (errorMessage != null) {
|
||||
return [
|
||||
SliverFillRemaining(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 48, color: Colors.red),
|
||||
const SizedBox(height: 16),
|
||||
Text(errorMessage!),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: loadItems,
|
||||
child: Text(t.common.retry),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
if (items.isEmpty && isLoading) {
|
||||
return [
|
||||
const SliverFillRemaining(
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
if (items.isEmpty) {
|
||||
final icon = emptyIcon;
|
||||
return [
|
||||
SliverFillRemaining(
|
||||
child: Center(
|
||||
child: icon != null
|
||||
? Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, size: 64, color: Colors.grey),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
emptyMessage,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Text(emptyMessage),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Build standard app bar actions (play, shuffle, delete)
|
||||
/// Subclasses can override to customize actions
|
||||
List<Widget> buildAppBarActions({
|
||||
VoidCallback? onDelete,
|
||||
String? deleteTooltip,
|
||||
Color? deleteColor,
|
||||
bool showDelete = true,
|
||||
}) {
|
||||
return [
|
||||
// Play button
|
||||
if (items.isNotEmpty)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.play_arrow),
|
||||
tooltip: t.discover.play,
|
||||
onPressed: playItems,
|
||||
),
|
||||
// Shuffle button
|
||||
if (items.isNotEmpty)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.shuffle),
|
||||
tooltip: t.common.shuffle,
|
||||
onPressed: shufflePlayItems,
|
||||
),
|
||||
// Delete button
|
||||
if (showDelete && onDelete != null)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
tooltip: deleteTooltip ?? t.common.delete,
|
||||
onPressed: onDelete,
|
||||
color: deleteColor ?? Colors.red,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/// Mixin that provides standard loadItems implementation for media lists
|
||||
/// Handles the common pattern of fetching, tagging, and setting items
|
||||
mixin StandardItemLoader<T extends StatefulWidget>
|
||||
on BaseMediaListDetailScreen<T> {
|
||||
/// Fetch items from the API (must be implemented by subclass)
|
||||
Future<List<PlexMetadata>> fetchItems();
|
||||
|
||||
/// Get error message for failed load (can be overridden)
|
||||
String getLoadErrorMessage(Object error) {
|
||||
return 'Failed to load items: ${error.toString()}';
|
||||
}
|
||||
|
||||
/// Get log message for successful load (can be overridden)
|
||||
String getLoadSuccessMessage(int itemCount) {
|
||||
return 'Loaded $itemCount items';
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> loadItems() async {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
errorMessage = null;
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Items are automatically tagged with server info by PlexClient
|
||||
final newItems = await fetchItems();
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
items = newItems;
|
||||
isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
appLogger.d(getLoadSuccessMessage(newItems.length));
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to load items', error: e);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
errorMessage = getLoadErrorMessage(e);
|
||||
isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
import '../widgets/media_card.dart';
|
||||
import '../widgets/desktop_app_bar.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../utils/grid_size_calculator.dart';
|
||||
import '../utils/dialogs.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import 'base_media_list_detail_screen.dart';
|
||||
|
||||
/// Screen to display the contents of a collection
|
||||
class CollectionDetailScreen extends StatefulWidget {
|
||||
final PlexMetadata collection;
|
||||
|
||||
const CollectionDetailScreen({super.key, required this.collection});
|
||||
|
||||
@override
|
||||
State<CollectionDetailScreen> createState() => _CollectionDetailScreenState();
|
||||
}
|
||||
|
||||
class _CollectionDetailScreenState
|
||||
extends BaseMediaListDetailScreen<CollectionDetailScreen>
|
||||
with StandardItemLoader<CollectionDetailScreen> {
|
||||
@override
|
||||
PlexMetadata get mediaItem => widget.collection;
|
||||
|
||||
@override
|
||||
String get title => widget.collection.title;
|
||||
|
||||
@override
|
||||
String get emptyMessage => t.collections.empty;
|
||||
|
||||
@override
|
||||
Future<List<PlexMetadata>> fetchItems() async {
|
||||
return await client.getCollectionItems(widget.collection.ratingKey);
|
||||
}
|
||||
|
||||
@override
|
||||
String getLoadErrorMessage(Object error) {
|
||||
return t.collections.failedToLoadItems(error: error.toString());
|
||||
}
|
||||
|
||||
@override
|
||||
String getLoadSuccessMessage(int itemCount) {
|
||||
return 'Loaded $itemCount items for collection: ${widget.collection.title}';
|
||||
}
|
||||
|
||||
Future<void> _deleteCollection() async {
|
||||
// Get library section ID from the collection or its items
|
||||
int? sectionId = widget.collection.librarySectionID;
|
||||
|
||||
// If collection doesn't have it, try to get it from loaded items
|
||||
if (sectionId == null && items.isNotEmpty) {
|
||||
sectionId = items.first.librarySectionID;
|
||||
}
|
||||
|
||||
if (sectionId == null) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(t.collections.unknownLibrarySection)),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Show confirmation dialog
|
||||
final confirmed = await showDeleteConfirmation(
|
||||
context,
|
||||
title: t.collections.deleteCollection,
|
||||
message: t.collections.deleteConfirm(title: widget.collection.title),
|
||||
);
|
||||
|
||||
if (confirmed != true) return;
|
||||
if (!mounted) return;
|
||||
|
||||
try {
|
||||
final success = await client.deleteCollection(
|
||||
sectionId.toString(),
|
||||
widget.collection.ratingKey,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (mounted) {
|
||||
if (success) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(t.collections.deleted)));
|
||||
Navigator.pop(
|
||||
context,
|
||||
true,
|
||||
); // Return true to indicate refresh needed
|
||||
} else {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(t.collections.deleteFailed)));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to delete collection', error: e);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
t.collections.deleteFailedWithError(error: e.toString()),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
CustomAppBar(
|
||||
title: Text(widget.collection.title),
|
||||
pinned: true,
|
||||
actions: buildAppBarActions(onDelete: _deleteCollection),
|
||||
),
|
||||
...buildStateSlivers(),
|
||||
if (items.isNotEmpty)
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 8),
|
||||
sliver: Consumer<SettingsProvider>(
|
||||
builder: (context, settingsProvider, child) {
|
||||
return SliverGrid(
|
||||
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent:
|
||||
GridSizeCalculator.getMaxCrossAxisExtent(
|
||||
context,
|
||||
settingsProvider.libraryDensity,
|
||||
),
|
||||
childAspectRatio: 2 / 3.3,
|
||||
crossAxisSpacing: 0,
|
||||
mainAxisSpacing: 0,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final item = items[index];
|
||||
return MediaCard(
|
||||
key: Key(item.ratingKey),
|
||||
item: item,
|
||||
onRefresh: updateItem,
|
||||
collectionId: widget.collection.ratingKey,
|
||||
onListRefresh: loadItems,
|
||||
);
|
||||
}, childCount: items.length),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,20 +4,26 @@ import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../client/plex_client.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../providers/plex_client_provider.dart';
|
||||
import '../services/storage_service.dart';
|
||||
import '../services/plex_auth_service.dart';
|
||||
import '../models/plex_hub.dart';
|
||||
import '../providers/multi_server_provider.dart';
|
||||
import '../providers/server_state_provider.dart';
|
||||
import '../providers/hidden_libraries_provider.dart';
|
||||
import '../providers/playback_state_provider.dart';
|
||||
import '../widgets/media_card.dart';
|
||||
import '../widgets/desktop_app_bar.dart';
|
||||
import '../widgets/user_avatar_widget.dart';
|
||||
import '../widgets/horizontal_scroll_with_arrows.dart';
|
||||
import '../widgets/hub_section.dart';
|
||||
import 'profile_switch_screen.dart';
|
||||
import 'server_selection_screen.dart';
|
||||
import '../providers/user_profile_provider.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
import '../mixins/refreshable.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../mixins/item_updatable.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
import '../utils/video_player_navigation.dart';
|
||||
import '../utils/content_rating_formatter.dart';
|
||||
import 'auth_screen.dart';
|
||||
|
||||
class DiscoverScreen extends StatefulWidget {
|
||||
@@ -31,11 +37,24 @@ class DiscoverScreen extends StatefulWidget {
|
||||
|
||||
class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
with Refreshable, ItemUpdatable, SingleTickerProviderStateMixin {
|
||||
static const Duration _heroAutoScrollDuration = Duration(seconds: 8);
|
||||
|
||||
@override
|
||||
PlexClient get client => context.clientSafe;
|
||||
PlexClient get client {
|
||||
final multiServerProvider = Provider.of<MultiServerProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
if (!multiServerProvider.hasConnectedServers) {
|
||||
throw Exception('No servers available');
|
||||
}
|
||||
return context.getClientForServer(
|
||||
multiServerProvider.onlineServerIds.first,
|
||||
);
|
||||
}
|
||||
|
||||
List<PlexMetadata> _onDeck = [];
|
||||
List<PlexMetadata> _recentlyAdded = [];
|
||||
List<PlexHub> _hubs = [];
|
||||
bool _isLoading = true;
|
||||
String? _errorMessage;
|
||||
final PageController _heroController = PageController();
|
||||
@@ -45,12 +64,31 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
late AnimationController _indicatorAnimationController;
|
||||
bool _isAutoScrollPaused = false;
|
||||
|
||||
/// Get the correct PlexClient for an item's server
|
||||
PlexClient _getClientForItem(PlexMetadata? item) {
|
||||
// Items should always have a serverId, but if not, fall back to first available server
|
||||
final serverId = item?.serverId;
|
||||
if (serverId == null) {
|
||||
final multiServerProvider = Provider.of<MultiServerProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
if (!multiServerProvider.hasConnectedServers) {
|
||||
throw Exception('No servers available');
|
||||
}
|
||||
return context.getClientForServer(
|
||||
multiServerProvider.onlineServerIds.first,
|
||||
);
|
||||
}
|
||||
return context.getClientForServer(serverId);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_indicatorAnimationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(seconds: 5),
|
||||
duration: _heroAutoScrollDuration,
|
||||
);
|
||||
_loadContent();
|
||||
_startAutoScroll();
|
||||
@@ -69,9 +107,17 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
if (_isAutoScrollPaused) return;
|
||||
|
||||
_indicatorAnimationController.forward(from: 0.0);
|
||||
_autoScrollTimer = Timer.periodic(const Duration(seconds: 5), (timer) {
|
||||
if (_onDeck.isEmpty || !_heroController.hasClients || _isAutoScrollPaused)
|
||||
_autoScrollTimer = Timer.periodic(_heroAutoScrollDuration, (timer) {
|
||||
if (_onDeck.isEmpty ||
|
||||
!_heroController.hasClients ||
|
||||
_isAutoScrollPaused) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate current index is within bounds before calculating next page
|
||||
if (_currentHeroIndex >= _onDeck.length) {
|
||||
_currentHeroIndex = 0;
|
||||
}
|
||||
|
||||
final nextPage = (_currentHeroIndex + 1) % _onDeck.length;
|
||||
_heroController.animateToPage(
|
||||
@@ -108,32 +154,98 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
_startAutoScroll();
|
||||
}
|
||||
|
||||
// Helper method to calculate visible dot range (max 5 dots)
|
||||
({int start, int end}) _getVisibleDotRange() {
|
||||
final totalDots = _onDeck.length;
|
||||
if (totalDots <= 5) {
|
||||
return (start: 0, end: totalDots - 1);
|
||||
}
|
||||
|
||||
// Center the active dot when possible
|
||||
final center = _currentHeroIndex;
|
||||
int start = (center - 2).clamp(0, totalDots - 5);
|
||||
int end = start + 4; // 5 dots total (0-4 inclusive)
|
||||
|
||||
return (start: start, end: end);
|
||||
}
|
||||
|
||||
// Helper method to determine dot size based on position
|
||||
double _getDotSize(int dotIndex, int start, int end) {
|
||||
final totalDots = _onDeck.length;
|
||||
|
||||
// If we have 5 or fewer dots, all are full size (8px)
|
||||
if (totalDots <= 5) {
|
||||
return 8.0;
|
||||
}
|
||||
|
||||
// First and last visible dots are smaller if there are more items beyond them
|
||||
final isFirstVisible = dotIndex == start && start > 0;
|
||||
final isLastVisible = dotIndex == end && end < totalDots - 1;
|
||||
|
||||
if (isFirstVisible || isLastVisible) {
|
||||
return 5.0; // Smaller edge dots
|
||||
}
|
||||
|
||||
return 8.0; // Normal size
|
||||
}
|
||||
|
||||
Future<void> _loadContent() async {
|
||||
appLogger.d('Loading discover content');
|
||||
appLogger.d('Loading discover content from all servers');
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
appLogger.d('Fetching onDeck and recentlyAdded from Plex');
|
||||
final clientProvider = context.plexClient;
|
||||
final client = clientProvider.client;
|
||||
if (client == null) {
|
||||
throw Exception('No client available');
|
||||
appLogger.d('Fetching onDeck and hubs from all Plex servers');
|
||||
final multiServerProvider = Provider.of<MultiServerProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
|
||||
if (!multiServerProvider.hasConnectedServers) {
|
||||
throw Exception('No servers available');
|
||||
}
|
||||
|
||||
final onDeck = await client.getOnDeck();
|
||||
final recentlyAdded = await client.getRecentlyAdded(limit: 20);
|
||||
// Fetch on deck and hubs from all servers in parallel for optimal performance
|
||||
final results = await Future.wait([
|
||||
multiServerProvider.aggregationService.getOnDeckFromAllServers(
|
||||
limit: 20,
|
||||
),
|
||||
multiServerProvider.aggregationService.getHubsFromAllServers(),
|
||||
]);
|
||||
|
||||
final onDeck = results[0] as List<PlexMetadata>;
|
||||
final allHubs = results[1] as List<PlexHub>;
|
||||
|
||||
// Filter out duplicate hubs that we already fetch separately
|
||||
final filteredHubs = allHubs.where((hub) {
|
||||
final hubId = hub.hubIdentifier?.toLowerCase() ?? '';
|
||||
final title = hub.title.toLowerCase();
|
||||
// Skip "Continue Watching" and "On Deck" hubs (we handle these separately)
|
||||
return !hubId.contains('ondeck') &&
|
||||
!hubId.contains('continue') &&
|
||||
!title.contains('continue watching') &&
|
||||
!title.contains('on deck');
|
||||
}).toList();
|
||||
|
||||
appLogger.d(
|
||||
'Received ${onDeck.length} on deck items and ${recentlyAdded.length} recently added items',
|
||||
'Received ${onDeck.length} on deck items and ${filteredHubs.length} hubs from all servers',
|
||||
);
|
||||
setState(() {
|
||||
_onDeck = onDeck;
|
||||
_recentlyAdded = recentlyAdded;
|
||||
_hubs = filteredHubs;
|
||||
_isLoading = false;
|
||||
|
||||
// Reset hero index to avoid sync issues
|
||||
_currentHeroIndex = 0;
|
||||
});
|
||||
|
||||
// Sync PageController to first page after data loads
|
||||
if (_heroController.hasClients && onDeck.isNotEmpty) {
|
||||
_heroController.jumpToPage(0);
|
||||
}
|
||||
|
||||
appLogger.d('Discover content loaded successfully');
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to load discover content', error: e);
|
||||
@@ -144,13 +256,155 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
}
|
||||
}
|
||||
|
||||
// Public method to refresh content
|
||||
/// Refresh only the Continue Watching section in the background
|
||||
/// This is called when returning to the home screen to avoid blocking UI
|
||||
Future<void> _refreshContinueWatching() async {
|
||||
appLogger.d('Refreshing Continue Watching in background from all servers');
|
||||
|
||||
try {
|
||||
final multiServerProvider = context.read<MultiServerProvider>();
|
||||
if (!multiServerProvider.hasConnectedServers) {
|
||||
appLogger.w('No servers available for background refresh');
|
||||
return;
|
||||
}
|
||||
|
||||
final onDeck = await multiServerProvider.aggregationService
|
||||
.getOnDeckFromAllServers(limit: 20);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_onDeck = onDeck;
|
||||
// Reset hero index if needed
|
||||
if (_currentHeroIndex >= onDeck.length) {
|
||||
_currentHeroIndex = 0;
|
||||
if (_heroController.hasClients && onDeck.isNotEmpty) {
|
||||
_heroController.jumpToPage(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
appLogger.d('Continue Watching refreshed successfully');
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.w('Failed to refresh Continue Watching', error: e);
|
||||
// Silently fail - don't show error to user for background refresh
|
||||
}
|
||||
}
|
||||
|
||||
// Public method to refresh content (for normal navigation)
|
||||
@override
|
||||
void refresh() {
|
||||
appLogger.d('DiscoverScreen.refresh() called');
|
||||
// Only refresh Continue Watching in background, not full screen reload
|
||||
_refreshContinueWatching();
|
||||
}
|
||||
|
||||
// Public method to fully reload all content (for profile switches)
|
||||
void fullRefresh() {
|
||||
appLogger.d('DiscoverScreen.fullRefresh() called - reloading all content');
|
||||
// Reload all content including On Deck and content hubs
|
||||
_loadContent();
|
||||
}
|
||||
|
||||
/// Get icon for hub based on its title
|
||||
IconData _getHubIcon(String title) {
|
||||
final lowerTitle = title.toLowerCase();
|
||||
|
||||
// Trending/Popular content
|
||||
if (lowerTitle.contains('trending')) {
|
||||
return Icons.trending_up;
|
||||
}
|
||||
if (lowerTitle.contains('popular') || lowerTitle.contains('imdb')) {
|
||||
return Icons.whatshot;
|
||||
}
|
||||
|
||||
// Seasonal/Time-based
|
||||
if (lowerTitle.contains('seasonal')) {
|
||||
return Icons.calendar_month;
|
||||
}
|
||||
if (lowerTitle.contains('newly') || lowerTitle.contains('new release')) {
|
||||
return Icons.new_releases;
|
||||
}
|
||||
if (lowerTitle.contains('recently released') ||
|
||||
lowerTitle.contains('recent')) {
|
||||
return Icons.schedule;
|
||||
}
|
||||
|
||||
// Top/Rated content
|
||||
if (lowerTitle.contains('top rated') ||
|
||||
lowerTitle.contains('highest rated')) {
|
||||
return Icons.star;
|
||||
}
|
||||
if (lowerTitle.contains('top ')) {
|
||||
return Icons.military_tech;
|
||||
}
|
||||
|
||||
// Genre-specific
|
||||
if (lowerTitle.contains('thriller')) {
|
||||
return Icons.warning_amber_rounded;
|
||||
}
|
||||
if (lowerTitle.contains('comedy') || lowerTitle.contains('comedier')) {
|
||||
return Icons.mood;
|
||||
}
|
||||
if (lowerTitle.contains('action')) {
|
||||
return Icons.flash_on;
|
||||
}
|
||||
if (lowerTitle.contains('drama')) {
|
||||
return Icons.theater_comedy;
|
||||
}
|
||||
if (lowerTitle.contains('fantasy')) {
|
||||
return Icons.auto_fix_high;
|
||||
}
|
||||
if (lowerTitle.contains('science') || lowerTitle.contains('sci-fi')) {
|
||||
return Icons.rocket_launch;
|
||||
}
|
||||
if (lowerTitle.contains('horror') || lowerTitle.contains('skräck')) {
|
||||
return Icons.nights_stay;
|
||||
}
|
||||
if (lowerTitle.contains('romance') || lowerTitle.contains('romantic')) {
|
||||
return Icons.favorite_border;
|
||||
}
|
||||
if (lowerTitle.contains('adventure') || lowerTitle.contains('äventyr')) {
|
||||
return Icons.explore;
|
||||
}
|
||||
|
||||
// Watchlist/Playlists
|
||||
if (lowerTitle.contains('playlist') || lowerTitle.contains('watchlist')) {
|
||||
return Icons.playlist_play;
|
||||
}
|
||||
if (lowerTitle.contains('unwatched') || lowerTitle.contains('unplayed')) {
|
||||
return Icons.visibility_off;
|
||||
}
|
||||
if (lowerTitle.contains('watched') || lowerTitle.contains('played')) {
|
||||
return Icons.visibility;
|
||||
}
|
||||
|
||||
// Network/Studio
|
||||
if (lowerTitle.contains('network') || lowerTitle.contains('more from')) {
|
||||
return Icons.tv;
|
||||
}
|
||||
|
||||
// Actor/Director
|
||||
if (lowerTitle.contains('actor') || lowerTitle.contains('director')) {
|
||||
return Icons.person;
|
||||
}
|
||||
|
||||
// Year-based (80s, 90s, etc.)
|
||||
if (lowerTitle.contains('80') ||
|
||||
lowerTitle.contains('90') ||
|
||||
lowerTitle.contains('00')) {
|
||||
return Icons.history;
|
||||
}
|
||||
|
||||
// Rediscover/Start Watching
|
||||
if (lowerTitle.contains('rediscover') ||
|
||||
lowerTitle.contains('start watching')) {
|
||||
return Icons.play_arrow;
|
||||
}
|
||||
|
||||
// Default icon for other hubs
|
||||
return Icons.auto_awesome;
|
||||
}
|
||||
|
||||
@override
|
||||
void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) {
|
||||
// Check and update in _onDeck list
|
||||
@@ -161,50 +415,13 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
_onDeck[onDeckIndex] = updatedMetadata;
|
||||
}
|
||||
|
||||
// Check and update in _recentlyAdded list
|
||||
final recentlyAddedIndex = _recentlyAdded.indexWhere(
|
||||
(item) => item.ratingKey == ratingKey,
|
||||
);
|
||||
if (recentlyAddedIndex != -1) {
|
||||
_recentlyAdded[recentlyAddedIndex] = updatedMetadata;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleSwitchServer() async {
|
||||
final storage = await StorageService.getInstance();
|
||||
final plexToken = storage.getPlexToken();
|
||||
|
||||
if (plexToken == null) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('No Plex token found. Please login again.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final authService = await PlexAuthService.create();
|
||||
|
||||
// Navigate to server selection screen
|
||||
if (mounted) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ServerSelectionScreen(
|
||||
authService: authService,
|
||||
plexToken: plexToken,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to initialize server selection: $e')),
|
||||
);
|
||||
// Check and update in hub items
|
||||
for (final hub in _hubs) {
|
||||
final itemIndex = hub.items.indexWhere(
|
||||
(item) => item.ratingKey == ratingKey,
|
||||
);
|
||||
if (itemIndex != -1) {
|
||||
hub.items[itemIndex] = updatedMetadata;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -213,16 +430,16 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Logout'),
|
||||
content: const Text('Are you sure you want to logout?'),
|
||||
title: Text(t.common.logout),
|
||||
content: Text(t.messages.logoutConfirm),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
child: Text(t.common.cancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Logout'),
|
||||
child: Text(t.common.logout),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -234,14 +451,19 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
final plexClientProvider = Provider.of<PlexClientProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
final plexClientProvider = context.plexClient;
|
||||
final multiServerProvider = context.read<MultiServerProvider>();
|
||||
final serverStateProvider = context.read<ServerStateProvider>();
|
||||
final hiddenLibrariesProvider = context.read<HiddenLibrariesProvider>();
|
||||
final playbackStateProvider = context.read<PlaybackStateProvider>();
|
||||
|
||||
// Clear all user data and provider states
|
||||
await userProfileProvider.logout();
|
||||
plexClientProvider.clearClient();
|
||||
multiServerProvider.clearAllConnections();
|
||||
serverStateProvider.reset();
|
||||
await hiddenLibrariesProvider.refresh();
|
||||
playbackStateProvider.clearShuffle();
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context).pushAndRemoveUntil(
|
||||
@@ -267,7 +489,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
controller: _scrollController,
|
||||
slivers: [
|
||||
DesktopSliverAppBar(
|
||||
title: const Text('Discover'),
|
||||
title: Text(t.discover.title),
|
||||
floating: true,
|
||||
pinned: true,
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
@@ -292,8 +514,6 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
onSelected: (value) {
|
||||
if (value == 'switch_profile') {
|
||||
_handleSwitchProfile(context);
|
||||
} else if (value == 'switch_server') {
|
||||
_handleSwitchServer();
|
||||
} else if (value == 'logout') {
|
||||
_handleLogout();
|
||||
}
|
||||
@@ -301,33 +521,23 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
itemBuilder: (context) => [
|
||||
// Only show Switch Profile if multiple users available
|
||||
if (userProvider.hasMultipleUsers)
|
||||
const PopupMenuItem(
|
||||
PopupMenuItem(
|
||||
value: 'switch_profile',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.people),
|
||||
SizedBox(width: 8),
|
||||
Text('Switch Profile'),
|
||||
Text(t.discover.switchProfile),
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'switch_server',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.swap_horiz),
|
||||
SizedBox(width: 8),
|
||||
Text('Switch Server'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
PopupMenuItem(
|
||||
value: 'logout',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.logout),
|
||||
SizedBox(width: 8),
|
||||
Text('Logout'),
|
||||
Text(t.discover.logout),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -357,7 +567,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _loadContent,
|
||||
child: const Text('Retry'),
|
||||
child: Text(t.common.retry),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -365,7 +575,14 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
),
|
||||
if (!_isLoading && _errorMessage == null) ...[
|
||||
// Hero Section (Continue Watching)
|
||||
if (_onDeck.isNotEmpty) _buildHeroSection(),
|
||||
Consumer<SettingsProvider>(
|
||||
builder: (context, settingsProvider, child) {
|
||||
if (_onDeck.isNotEmpty && settingsProvider.showHeroSection) {
|
||||
return _buildHeroSection();
|
||||
}
|
||||
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
||||
},
|
||||
),
|
||||
|
||||
// On Deck / Continue Watching
|
||||
if (_onDeck.isNotEmpty) ...[
|
||||
@@ -377,38 +594,32 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
const Icon(Icons.play_circle_outline),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Continue Watching',
|
||||
t.discover.continueWatching,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildHorizontalList(_onDeck, isLarge: false),
|
||||
_buildHorizontalList(
|
||||
_onDeck,
|
||||
isLarge: false,
|
||||
isInContinueWatching: true,
|
||||
),
|
||||
],
|
||||
|
||||
// Recently Added
|
||||
if (_recentlyAdded.isNotEmpty) ...[
|
||||
// Recommendation Hubs (Trending, Top in Genre, etc.)
|
||||
for (final hub in _hubs)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 24, 16, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.fiber_new),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Recently Added',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: HubSection(
|
||||
hub: hub,
|
||||
icon: _getHubIcon(hub.title),
|
||||
onRefresh: updateItem,
|
||||
),
|
||||
),
|
||||
_buildHorizontalList(_recentlyAdded, isLarge: false),
|
||||
],
|
||||
|
||||
if (_onDeck.isEmpty && _recentlyAdded.isEmpty)
|
||||
const SliverFillRemaining(
|
||||
if (_onDeck.isEmpty && _hubs.isEmpty)
|
||||
SliverFillRemaining(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
@@ -419,10 +630,10 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
color: Colors.grey,
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
Text('No content available'),
|
||||
Text(t.discover.noContentAvailable),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'Add some media to your libraries',
|
||||
t.discover.addMediaToLibraries,
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
],
|
||||
@@ -448,10 +659,13 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
controller: _heroController,
|
||||
itemCount: _onDeck.length,
|
||||
onPageChanged: (index) {
|
||||
setState(() {
|
||||
_currentHeroIndex = index;
|
||||
});
|
||||
_resetAutoScrollTimer();
|
||||
// Validate index is within bounds before updating
|
||||
if (index >= 0 && index < _onDeck.length) {
|
||||
setState(() {
|
||||
_currentHeroIndex = index;
|
||||
});
|
||||
_resetAutoScrollTimer();
|
||||
}
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
return _buildHeroItem(_onDeck[index]);
|
||||
@@ -479,62 +693,79 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
color: Colors.white,
|
||||
size: 18,
|
||||
semanticLabel:
|
||||
'${_isAutoScrollPaused ? 'Play' : 'Pause'} auto-scroll',
|
||||
'${_isAutoScrollPaused ? t.discover.play : t.discover.pause} auto-scroll',
|
||||
),
|
||||
),
|
||||
// Spacer to separate indicators from button
|
||||
const SizedBox(width: 8),
|
||||
// Page indicators
|
||||
...List.generate(_onDeck.length, (index) {
|
||||
final isActive = _currentHeroIndex == index;
|
||||
if (isActive) {
|
||||
// Animated progress indicator for active page
|
||||
return AnimatedBuilder(
|
||||
animation: _indicatorAnimationController,
|
||||
builder: (context, child) {
|
||||
// Fill width animates from 8px to 24px
|
||||
final fillWidth =
|
||||
8.0 +
|
||||
(16.0 * _indicatorAnimationController.value);
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
width: 24,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Container(
|
||||
width: fillWidth,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
// Page indicators (limited to 5 dots)
|
||||
...() {
|
||||
final range = _getVisibleDotRange();
|
||||
return List.generate(range.end - range.start + 1, (i) {
|
||||
final index = range.start + i;
|
||||
final isActive = _currentHeroIndex == index;
|
||||
final dotSize = _getDotSize(
|
||||
index,
|
||||
range.start,
|
||||
range.end,
|
||||
);
|
||||
|
||||
if (isActive) {
|
||||
// Animated progress indicator for active page
|
||||
return AnimatedBuilder(
|
||||
animation: _indicatorAnimationController,
|
||||
builder: (context, child) {
|
||||
// Fill width animates based on dot size
|
||||
final maxWidth =
|
||||
dotSize * 3; // 24px for normal, 15px for small
|
||||
final fillWidth =
|
||||
dotSize +
|
||||
((maxWidth - dotSize) *
|
||||
_indicatorAnimationController.value);
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
width: maxWidth,
|
||||
height: dotSize,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(
|
||||
dotSize / 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// Static indicator for inactive pages
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
);
|
||||
}
|
||||
}),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Container(
|
||||
width: fillWidth,
|
||||
height: dotSize,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(
|
||||
dotSize / 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// Static indicator for inactive pages
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
width: dotSize,
|
||||
height: dotSize,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(dotSize / 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}(),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -552,20 +783,20 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
|
||||
// Determine content type label for chip
|
||||
final contentTypeLabel = heroItem.type.toLowerCase() == 'movie'
|
||||
? 'Movie'
|
||||
: 'TV Show';
|
||||
? t.discover.movie
|
||||
: t.discover.tvShow;
|
||||
|
||||
// Build semantic label for hero item
|
||||
final heroLabel = isEpisode
|
||||
? "${heroItem.grandparentTitle}, ${heroItem.title}"
|
||||
: heroItem.title;
|
||||
|
||||
return Semantics(
|
||||
label: "media-hero-${heroItem.ratingKey}",
|
||||
identifier: "media-hero-${heroItem.ratingKey}",
|
||||
label: heroLabel,
|
||||
button: true,
|
||||
hint: "Tap to play ${heroItem.title}",
|
||||
hint: t.accessibility.tapToPlay,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
final clientProvider = context.plexClient;
|
||||
final client = clientProvider.client;
|
||||
if (client == null) return;
|
||||
|
||||
appLogger.d('Navigating to VideoPlayerScreen for: ${heroItem.title}');
|
||||
navigateToVideoPlayer(context, metadata: heroItem);
|
||||
},
|
||||
@@ -609,16 +840,9 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
child: Opacity(opacity: value, child: child),
|
||||
);
|
||||
},
|
||||
child: Consumer<PlexClientProvider>(
|
||||
builder: (context, clientProvider, child) {
|
||||
final client = clientProvider.client;
|
||||
if (client == null) {
|
||||
return Container(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
);
|
||||
}
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final client = _getClientForItem(heroItem);
|
||||
return CachedNetworkImage(
|
||||
imageUrl: client.getThumbnailUrl(
|
||||
heroItem.art ?? heroItem.grandparentArt,
|
||||
@@ -682,12 +906,9 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
SizedBox(
|
||||
height: 120,
|
||||
width: 400,
|
||||
child: Consumer<PlexClientProvider>(
|
||||
builder: (context, clientProvider, child) {
|
||||
final client = clientProvider.client;
|
||||
if (client == null) {
|
||||
return Container();
|
||||
}
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final client = _getClientForItem(heroItem);
|
||||
return CachedNetworkImage(
|
||||
imageUrl: client.getThumbnailUrl(
|
||||
heroItem.clearLogo,
|
||||
@@ -793,9 +1014,9 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
[
|
||||
contentTypeLabel,
|
||||
if (heroItem.rating != null)
|
||||
'★ ${(heroItem.rating! / 10).toStringAsFixed(1)}',
|
||||
'★ ${heroItem.rating!.toStringAsFixed(1)}',
|
||||
if (heroItem.contentRating != null)
|
||||
heroItem.contentRating!,
|
||||
formatContentRating(heroItem.contentRating!),
|
||||
if (heroItem.year != null)
|
||||
heroItem.year.toString(),
|
||||
].join(' • '),
|
||||
@@ -887,10 +1108,6 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
final clientProvider = context.plexClient;
|
||||
final client = clientProvider.client;
|
||||
if (client == null) return;
|
||||
|
||||
appLogger.d('Playing: ${heroItem.title}');
|
||||
navigateToVideoPlayer(context, metadata: heroItem);
|
||||
},
|
||||
@@ -928,7 +1145,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'$minutesLeft min left',
|
||||
t.discover.minutesLeft(minutes: minutesLeft),
|
||||
style: const TextStyle(
|
||||
color: Colors.black,
|
||||
fontSize: 14,
|
||||
@@ -936,8 +1153,8 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
),
|
||||
),
|
||||
] else
|
||||
const Text(
|
||||
'Play',
|
||||
Text(
|
||||
t.discover.play,
|
||||
style: TextStyle(
|
||||
color: Colors.black,
|
||||
fontSize: 14,
|
||||
@@ -953,45 +1170,58 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
Widget _buildHorizontalList(
|
||||
List<PlexMetadata> items, {
|
||||
bool isLarge = false,
|
||||
bool isInContinueWatching = false,
|
||||
}) {
|
||||
return SliverToBoxAdapter(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Responsive card width based on screen size
|
||||
// Match Libraries screen sizing (190px baseline)
|
||||
final screenWidth = constraints.maxWidth;
|
||||
final cardWidth = screenWidth > 1600
|
||||
? 220.0
|
||||
: screenWidth > 1200
|
||||
? 200.0
|
||||
: screenWidth > 800
|
||||
? 160.0
|
||||
: 130.0;
|
||||
? 190.0
|
||||
: 160.0;
|
||||
|
||||
// MediaCard has 8px padding on all sides (16px total horizontally)
|
||||
// So actual poster width is cardWidth - 16
|
||||
final posterWidth = cardWidth - 16;
|
||||
// 2:3 poster aspect ratio (height is 1.5x width)
|
||||
final cardHeight = cardWidth * 1.5;
|
||||
final posterHeight = posterWidth * 1.5;
|
||||
// Container height = poster + padding + spacing + text
|
||||
// 8px top padding + cardHeight + 4px spacing + ~26px text + 8px bottom padding
|
||||
final containerHeight = cardHeight + 46;
|
||||
// 8px top padding + posterHeight + 4px spacing + ~26px text + 8px bottom padding
|
||||
final containerHeight = posterHeight + 46;
|
||||
|
||||
return SizedBox(
|
||||
height: containerHeight,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||
child: MediaCard(
|
||||
key: Key(item.ratingKey),
|
||||
item: item,
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
onRefresh: updateItem,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: HorizontalScrollWithArrows(
|
||||
builder: (scrollController) => ListView.builder(
|
||||
controller: scrollController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||
child: MediaCard(
|
||||
key: Key(item.ratingKey),
|
||||
item: item,
|
||||
width: cardWidth,
|
||||
height: posterHeight,
|
||||
onRefresh: updateItem,
|
||||
onRemoveFromContinueWatching: isInContinueWatching
|
||||
? _refreshContinueWatching
|
||||
: null,
|
||||
forceGridMode: true,
|
||||
isInContinueWatching: isInContinueWatching,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../client/plex_client.dart';
|
||||
import '../models/plex_hub.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../models/plex_sort.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/grid_cross_axis_extent.dart';
|
||||
import '../widgets/media_card.dart';
|
||||
import '../widgets/desktop_app_bar.dart';
|
||||
import '../widgets/sort_bottom_sheet.dart';
|
||||
import '../mixins/refreshable.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
|
||||
/// Screen to display full content of a recommendation hub
|
||||
class HubDetailScreen extends StatefulWidget {
|
||||
final PlexHub hub;
|
||||
|
||||
const HubDetailScreen({super.key, required this.hub});
|
||||
|
||||
@override
|
||||
State<HubDetailScreen> createState() => _HubDetailScreenState();
|
||||
}
|
||||
|
||||
class _HubDetailScreenState extends State<HubDetailScreen> with Refreshable {
|
||||
PlexClient get client => _getClientForHub();
|
||||
|
||||
List<PlexMetadata> _items = [];
|
||||
List<PlexMetadata> _filteredItems = [];
|
||||
List<PlexSort> _sortOptions = [];
|
||||
PlexSort? _selectedSort;
|
||||
bool _isSortDescending = false;
|
||||
bool _isLoading = false;
|
||||
String? _errorMessage;
|
||||
|
||||
/// Get the correct PlexClient for this hub's server
|
||||
PlexClient _getClientForHub() {
|
||||
return context.getClientForServer(widget.hub.serverId!);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Start with items already loaded in the hub
|
||||
_items = widget.hub.items;
|
||||
_filteredItems = widget.hub.items;
|
||||
// Load more items if available
|
||||
if (widget.hub.more) {
|
||||
_loadMoreItems();
|
||||
}
|
||||
// Load sorts based on the library type
|
||||
_loadSorts();
|
||||
}
|
||||
|
||||
Future<void> _loadSorts() async {
|
||||
try {
|
||||
final client = _getClientForHub();
|
||||
|
||||
// Get the library key from the hub key
|
||||
// Hub keys can have various formats:
|
||||
// - /hubs/sections/1/...
|
||||
// - /library/sections/1/all?...
|
||||
final hubKey = widget.hub.hubKey;
|
||||
appLogger.d('Hub key: $hubKey');
|
||||
|
||||
// Try different patterns
|
||||
RegExpMatch? match = RegExp(r'/hubs/sections/(\d+)').firstMatch(hubKey);
|
||||
match ??= RegExp(r'/library/sections/(\d+)').firstMatch(hubKey);
|
||||
match ??= RegExp(r'sections/(\d+)').firstMatch(hubKey);
|
||||
|
||||
if (match != null) {
|
||||
final sectionId = match.group(1)!;
|
||||
appLogger.d('Loading sorts for section: $sectionId');
|
||||
|
||||
// Load sorts for this library
|
||||
final sorts = await client.getLibrarySorts(sectionId);
|
||||
|
||||
appLogger.d('Loaded ${sorts.length} sorts');
|
||||
|
||||
setState(() {
|
||||
_sortOptions = sorts.isNotEmpty ? sorts : _getDefaultSortOptions();
|
||||
// Don't set a default sort - let items stay in original order
|
||||
});
|
||||
} else {
|
||||
appLogger.w('Could not extract section ID from hub key: $hubKey');
|
||||
// Provide default sort options even if we can't get library-specific ones
|
||||
setState(() {
|
||||
_sortOptions = _getDefaultSortOptions();
|
||||
// Don't set a default sort - let items stay in original order
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to load sorts', error: e);
|
||||
// Provide default sort options on error
|
||||
setState(() {
|
||||
_sortOptions = _getDefaultSortOptions();
|
||||
// Don't set a default sort - let items stay in original order
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
List<PlexSort> _getDefaultSortOptions() {
|
||||
return [
|
||||
PlexSort(
|
||||
key: 'titleSort',
|
||||
title: t.hubDetail.title,
|
||||
defaultDirection: 'asc',
|
||||
),
|
||||
PlexSort(
|
||||
key: 'year',
|
||||
descKey: 'year:desc',
|
||||
title: t.hubDetail.releaseYear,
|
||||
defaultDirection: 'desc',
|
||||
),
|
||||
PlexSort(
|
||||
key: 'addedAt',
|
||||
descKey: 'addedAt:desc',
|
||||
title: t.hubDetail.dateAdded,
|
||||
defaultDirection: 'desc',
|
||||
),
|
||||
PlexSort(
|
||||
key: 'rating',
|
||||
descKey: 'rating:desc',
|
||||
title: t.hubDetail.rating,
|
||||
defaultDirection: 'desc',
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
void _applySort() {
|
||||
setState(() {
|
||||
_filteredItems = List.from(_items);
|
||||
|
||||
// Apply sorting
|
||||
if (_selectedSort != null) {
|
||||
final sortKey = _selectedSort!.key;
|
||||
_filteredItems.sort((a, b) {
|
||||
int comparison = 0;
|
||||
|
||||
switch (sortKey) {
|
||||
case 'titleSort':
|
||||
case 'title':
|
||||
comparison = a.title.compareTo(b.title);
|
||||
break;
|
||||
case 'addedAt':
|
||||
comparison = (a.addedAt ?? 0).compareTo(b.addedAt ?? 0);
|
||||
break;
|
||||
case 'originallyAvailableAt':
|
||||
case 'year':
|
||||
comparison = (a.year ?? 0).compareTo(b.year ?? 0);
|
||||
break;
|
||||
case 'rating':
|
||||
comparison = (a.rating ?? 0).compareTo(b.rating ?? 0);
|
||||
break;
|
||||
default:
|
||||
comparison = a.title.compareTo(b.title);
|
||||
}
|
||||
|
||||
return _isSortDescending ? -comparison : comparison;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _showSortBottomSheet() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => SortBottomSheet(
|
||||
sortOptions: _sortOptions,
|
||||
selectedSort: _selectedSort,
|
||||
isSortDescending: _isSortDescending,
|
||||
onSortChanged: (sort, descending) {
|
||||
setState(() {
|
||||
_selectedSort = sort;
|
||||
_isSortDescending = descending;
|
||||
});
|
||||
_applySort();
|
||||
},
|
||||
onClear: () {
|
||||
setState(() {
|
||||
// Reset to no sorting (original order)
|
||||
_selectedSort = null;
|
||||
_isSortDescending = false;
|
||||
});
|
||||
_applySort();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadMoreItems() async {
|
||||
if (_isLoading) return;
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final client = _getClientForHub();
|
||||
|
||||
// Fetch items from the hub, tagged with server info at the source
|
||||
final items = await client.getHubContent(widget.hub.hubKey);
|
||||
|
||||
setState(() {
|
||||
_items = items;
|
||||
_filteredItems = items;
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
// Apply any existing sort
|
||||
_applySort();
|
||||
|
||||
appLogger.d('Loaded ${items.length} items for hub: ${widget.hub.title}');
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to load hub content', error: e);
|
||||
setState(() {
|
||||
_errorMessage = t.messages.errorLoading(error: e.toString());
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _handleItemRefresh(String ratingKey) {
|
||||
// Refresh the specific item in the list
|
||||
setState(() {
|
||||
final index = _items.indexWhere((item) => item.ratingKey == ratingKey);
|
||||
if (index != -1) {
|
||||
// The item will be refreshed by the MediaCard itself
|
||||
appLogger.d('Item refresh requested for: $ratingKey');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void refresh() {
|
||||
_loadMoreItems();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
CustomAppBar(
|
||||
title: Text(widget.hub.title),
|
||||
pinned: true,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.swap_vert, semanticLabel: t.libraries.sort),
|
||||
onPressed: _showSortBottomSheet,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_errorMessage != null)
|
||||
SliverFillRemaining(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
size: 48,
|
||||
color: Colors.red,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(_errorMessage!),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _loadMoreItems,
|
||||
child: Text(t.common.retry),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (_filteredItems.isEmpty && _isLoading)
|
||||
const SliverFillRemaining(
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
else if (_filteredItems.isEmpty)
|
||||
SliverFillRemaining(
|
||||
child: Center(child: Text(t.hubDetail.noItemsFound)),
|
||||
)
|
||||
else
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
|
||||
sliver: SliverGrid(
|
||||
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: getMaxCrossAxisExtentWithPadding(
|
||||
context,
|
||||
context.watch<SettingsProvider>().libraryDensity,
|
||||
16,
|
||||
),
|
||||
childAspectRatio: 2 / 3.3,
|
||||
crossAxisSpacing: 0,
|
||||
mainAxisSpacing: 0,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
return MediaCard(
|
||||
item: _filteredItems[index],
|
||||
onRefresh: _handleItemRefresh,
|
||||
);
|
||||
}, childCount: _filteredItems.length),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../models/plex_library.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
import '../../mixins/library_tab_state.dart';
|
||||
import '../../mixins/refreshable.dart';
|
||||
import '../../widgets/content_state_builder.dart';
|
||||
|
||||
/// Base class for library tab screens that provides common state management
|
||||
/// and lifecycle handling for tabs that display library content.
|
||||
///
|
||||
/// Type parameter T: The type of items this tab displays
|
||||
///
|
||||
/// Subclasses must implement:
|
||||
/// - [loadData]: Load data from the Plex API
|
||||
/// - [buildContent]: Build the UI for displaying loaded items
|
||||
///
|
||||
/// Optional overrides:
|
||||
/// - [emptyIcon]: Icon to show when there are no items
|
||||
/// - [emptyMessage]: Message to show when there are no items
|
||||
/// - [errorContext]: Context for error messages (defaults to "content")
|
||||
/// - [getRefreshStream]: Stream to listen for refresh events
|
||||
abstract class BaseLibraryTab<T> extends StatefulWidget {
|
||||
final PlexLibrary library;
|
||||
final String? viewMode;
|
||||
final String? density;
|
||||
|
||||
const BaseLibraryTab({
|
||||
super.key,
|
||||
required this.library,
|
||||
this.viewMode,
|
||||
this.density,
|
||||
});
|
||||
}
|
||||
|
||||
/// State mixin that provides the common implementation for library tabs
|
||||
/// This preserves AutomaticKeepAliveClientMixin functionality
|
||||
abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>>
|
||||
extends State<W>
|
||||
with AutomaticKeepAliveClientMixin, Refreshable, LibraryTabStateMixin {
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
@override
|
||||
PlexLibrary get library => widget.library;
|
||||
|
||||
@override
|
||||
void refresh() {
|
||||
loadItems();
|
||||
}
|
||||
|
||||
// State management
|
||||
List<T> _items = [];
|
||||
bool _isLoading = false;
|
||||
String? _errorMessage;
|
||||
StreamSubscription<void>? _refreshSubscription;
|
||||
|
||||
// Getters for subclasses
|
||||
List<T> get items => _items;
|
||||
bool get isLoading => _isLoading;
|
||||
String? get errorMessage => _errorMessage;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
loadItems();
|
||||
|
||||
// Subscribe to refresh stream if provided
|
||||
final refreshStream = getRefreshStream();
|
||||
if (refreshStream != null) {
|
||||
_refreshSubscription = refreshStream.listen((_) {
|
||||
if (mounted) {
|
||||
loadItems();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_refreshSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(W oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
// Reload if library changed
|
||||
if (oldWidget.library.globalKey != widget.library.globalKey) {
|
||||
loadItems();
|
||||
}
|
||||
}
|
||||
|
||||
/// Load items from the API
|
||||
/// This is the main data loading function that subclasses must implement
|
||||
Future<List<T>> loadData();
|
||||
|
||||
/// Build the content widget given the loaded items
|
||||
/// This is called by ContentStateBuilder when items are available
|
||||
Widget buildContent(List<T> items);
|
||||
|
||||
/// Icon to display when there are no items (empty state)
|
||||
IconData get emptyIcon;
|
||||
|
||||
/// Message to display when there are no items (empty state)
|
||||
String get emptyMessage;
|
||||
|
||||
/// Context string for error messages (e.g., "playlists", "collections")
|
||||
String get errorContext;
|
||||
|
||||
/// Optional refresh stream to listen for external refresh events
|
||||
/// Return null if no refresh stream is needed
|
||||
Stream<void>? getRefreshStream() => null;
|
||||
|
||||
/// Load items with error handling and state management
|
||||
Future<void> loadItems() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final loadedItems = await loadData();
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_items = loadedItems;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
|
||||
appLogger.e('Error loading $errorContext', error: e);
|
||||
setState(() {
|
||||
_errorMessage = 'Failed to load $errorContext: ${e.toString()}';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
||||
|
||||
return ContentStateBuilder<T>(
|
||||
isLoading: _isLoading,
|
||||
errorMessage: _errorMessage,
|
||||
items: _items,
|
||||
emptyIcon: emptyIcon,
|
||||
emptyMessage: emptyMessage,
|
||||
onRetry: loadItems,
|
||||
builder: (items) =>
|
||||
RefreshIndicator(onRefresh: loadItems, child: buildContent(items)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,584 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import '../../client/plex_client.dart';
|
||||
import '../../models/plex_library.dart';
|
||||
import '../../models/plex_metadata.dart';
|
||||
import '../../models/plex_filter.dart';
|
||||
import '../../models/plex_sort.dart';
|
||||
import '../../providers/settings_provider.dart';
|
||||
import '../../utils/error_message_utils.dart';
|
||||
import '../../utils/grid_size_calculator.dart';
|
||||
import '../../widgets/media_card.dart';
|
||||
import '../../widgets/folder_tree_view.dart';
|
||||
import '../../widgets/filters_bottom_sheet.dart';
|
||||
import '../../widgets/sort_bottom_sheet.dart';
|
||||
import '../../widgets/empty_state_widget.dart';
|
||||
import '../../widgets/error_state_widget.dart';
|
||||
import '../../services/storage_service.dart';
|
||||
import '../../services/settings_service.dart' show ViewMode;
|
||||
import '../../mixins/item_updatable.dart';
|
||||
import '../../mixins/library_tab_state.dart';
|
||||
import '../../mixins/refreshable.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
|
||||
/// Browse tab for library screen
|
||||
/// Shows library items with grouping, filtering, and sorting
|
||||
class LibraryBrowseTab extends StatefulWidget {
|
||||
final PlexLibrary library;
|
||||
final String? viewMode;
|
||||
final String? density;
|
||||
|
||||
const LibraryBrowseTab({
|
||||
super.key,
|
||||
required this.library,
|
||||
this.viewMode,
|
||||
this.density,
|
||||
});
|
||||
|
||||
@override
|
||||
State<LibraryBrowseTab> createState() => _LibraryBrowseTabState();
|
||||
}
|
||||
|
||||
class _LibraryBrowseTabState extends State<LibraryBrowseTab>
|
||||
with
|
||||
AutomaticKeepAliveClientMixin,
|
||||
ItemUpdatable,
|
||||
Refreshable,
|
||||
LibraryTabStateMixin {
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
@override
|
||||
PlexLibrary get library => widget.library;
|
||||
|
||||
@override
|
||||
PlexClient get client => getClientForLibrary();
|
||||
|
||||
@override
|
||||
void refresh() {
|
||||
_loadContent();
|
||||
}
|
||||
|
||||
@override
|
||||
void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) {
|
||||
setState(() {
|
||||
final index = _items.indexWhere((item) => item.ratingKey == ratingKey);
|
||||
if (index != -1) {
|
||||
_items[index] = updatedMetadata;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
List<PlexMetadata> _items = [];
|
||||
List<PlexFilter> _filters = [];
|
||||
List<PlexSort> _sortOptions = [];
|
||||
bool _isLoading = false;
|
||||
String? _errorMessage;
|
||||
Map<String, String> _selectedFilters = {};
|
||||
PlexSort? _selectedSort;
|
||||
bool _isSortDescending = false;
|
||||
String _selectedGrouping = 'all'; // all, seasons, episodes, folders
|
||||
|
||||
// Pagination state
|
||||
int _currentPage = 0;
|
||||
bool _hasMoreItems = true;
|
||||
CancelToken? _cancelToken;
|
||||
int _requestId = 0;
|
||||
static const int _pageSize = 500;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadContent();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(LibraryBrowseTab oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
// Reload if library changed
|
||||
if (oldWidget.library.globalKey != widget.library.globalKey) {
|
||||
_loadContent();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_cancelToken?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadContent() async {
|
||||
// Cancel any pending request
|
||||
_cancelToken?.cancel();
|
||||
_cancelToken = CancelToken();
|
||||
final currentRequestId = ++_requestId;
|
||||
|
||||
// Extract context dependencies before async gap - use server-specific client
|
||||
final client = getClientForLibrary();
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
_items = [];
|
||||
_currentPage = 0;
|
||||
_hasMoreItems = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final storage = await StorageService.getInstance();
|
||||
|
||||
// Load filters and sorts for this library
|
||||
final filters = await client.getLibraryFilters(widget.library.key);
|
||||
final sorts = await client.getLibrarySorts(widget.library.key);
|
||||
|
||||
// Load saved preferences
|
||||
final savedFilters = storage.getLibraryFilters(
|
||||
sectionId: widget.library.globalKey,
|
||||
);
|
||||
final savedSort = storage.getLibrarySort(widget.library.globalKey);
|
||||
final savedGrouping = storage.getLibraryGrouping(
|
||||
widget.library.globalKey,
|
||||
);
|
||||
|
||||
// Check if request was cancelled
|
||||
if (currentRequestId != _requestId) return;
|
||||
|
||||
setState(() {
|
||||
_filters = filters;
|
||||
_sortOptions = sorts;
|
||||
_selectedFilters = Map.from(savedFilters);
|
||||
_selectedGrouping = savedGrouping ?? _getDefaultGrouping();
|
||||
|
||||
// Restore sort
|
||||
if (savedSort != null) {
|
||||
final sortKey = savedSort['key'] as String?;
|
||||
if (sortKey != null) {
|
||||
final sort = sorts.where((s) => s.key == sortKey).firstOrNull;
|
||||
if (sort != null) {
|
||||
_selectedSort = sort;
|
||||
_isSortDescending = (savedSort['descending'] as bool?) ?? false;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Load items
|
||||
await _loadItems();
|
||||
} catch (e) {
|
||||
if (currentRequestId != _requestId) return;
|
||||
|
||||
setState(() {
|
||||
_errorMessage = _getErrorMessage(e);
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadItems({bool loadMore = false}) async {
|
||||
if (loadMore && _isLoading) return;
|
||||
|
||||
if (!loadMore) {
|
||||
_currentPage = 0;
|
||||
_hasMoreItems = true;
|
||||
}
|
||||
|
||||
if (!_hasMoreItems) return;
|
||||
|
||||
final currentRequestId = _requestId;
|
||||
_cancelToken?.cancel();
|
||||
_cancelToken = CancelToken();
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
if (!loadMore) {
|
||||
_items = [];
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
// Use server-specific client for this library
|
||||
final client = getClientForLibrary();
|
||||
|
||||
// Build filter params
|
||||
final filterParams = Map<String, String>.from(_selectedFilters);
|
||||
|
||||
// Add grouping type filter (but not for 'all' or 'folders')
|
||||
if (_selectedGrouping != 'all' && _selectedGrouping != 'folders') {
|
||||
final typeId = _getGroupingTypeId();
|
||||
if (typeId.isNotEmpty) {
|
||||
filterParams['type'] = typeId;
|
||||
}
|
||||
}
|
||||
|
||||
// Add sort
|
||||
if (_selectedSort != null) {
|
||||
filterParams['sort'] = _selectedSort!.getSortKey(
|
||||
descending: _isSortDescending,
|
||||
);
|
||||
}
|
||||
|
||||
// Items are automatically tagged with server info by PlexClient
|
||||
final items = await client.getLibraryContent(
|
||||
widget.library.key,
|
||||
start: _currentPage * _pageSize,
|
||||
size: _pageSize,
|
||||
filters: filterParams,
|
||||
cancelToken: _cancelToken,
|
||||
);
|
||||
|
||||
if (currentRequestId != _requestId) return;
|
||||
|
||||
setState(() {
|
||||
if (loadMore) {
|
||||
_items.addAll(items);
|
||||
} else {
|
||||
_items = items;
|
||||
}
|
||||
_hasMoreItems = items.length >= _pageSize;
|
||||
_currentPage++;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (currentRequestId != _requestId) return;
|
||||
|
||||
setState(() {
|
||||
_errorMessage = _getErrorMessage(e);
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
String _getDefaultGrouping() {
|
||||
final type = widget.library.type.toLowerCase();
|
||||
if (type == 'show') {
|
||||
return 'shows';
|
||||
} else if (type == 'movie') {
|
||||
return 'movies';
|
||||
}
|
||||
return 'all';
|
||||
}
|
||||
|
||||
String _getGroupingTypeId() {
|
||||
switch (_selectedGrouping) {
|
||||
case 'movies':
|
||||
return '1';
|
||||
case 'shows':
|
||||
return '2';
|
||||
case 'seasons':
|
||||
return '3';
|
||||
case 'episodes':
|
||||
return '4';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
List<String> _getGroupingOptions() {
|
||||
final type = widget.library.type.toLowerCase();
|
||||
if (type == 'show') {
|
||||
return ['shows', 'seasons', 'episodes', 'folders'];
|
||||
} else if (type == 'movie') {
|
||||
return ['movies', 'folders'];
|
||||
}
|
||||
// All library types support folder browsing
|
||||
return ['all', 'folders'];
|
||||
}
|
||||
|
||||
String _getGroupingLabel(String grouping) {
|
||||
switch (grouping) {
|
||||
case 'movies':
|
||||
return t.libraries.groupings.movies;
|
||||
case 'shows':
|
||||
return t.libraries.groupings.shows;
|
||||
case 'seasons':
|
||||
return t.libraries.groupings.seasons;
|
||||
case 'episodes':
|
||||
return t.libraries.groupings.episodes;
|
||||
case 'folders':
|
||||
return t.libraries.groupings.folders;
|
||||
default:
|
||||
return t.libraries.groupings.all;
|
||||
}
|
||||
}
|
||||
|
||||
String _getErrorMessage(dynamic error) {
|
||||
if (error is DioException) {
|
||||
return mapDioErrorToMessage(error, context: t.libraries.content);
|
||||
}
|
||||
return mapUnexpectedErrorToMessage(error, context: t.libraries.content);
|
||||
}
|
||||
|
||||
void _showGroupingBottomSheet() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return ListView(
|
||||
shrinkWrap: true,
|
||||
children: _getGroupingOptions().map((grouping) {
|
||||
return RadioListTile<String>(
|
||||
title: Text(_getGroupingLabel(grouping)),
|
||||
value: grouping,
|
||||
// ignore: deprecated_member_use
|
||||
groupValue: _selectedGrouping,
|
||||
// ignore: deprecated_member_use
|
||||
onChanged: (value) async {
|
||||
if (value != null) {
|
||||
setState(() {
|
||||
_selectedGrouping = value;
|
||||
});
|
||||
|
||||
final storage = await StorageService.getInstance();
|
||||
await storage.saveLibraryGrouping(
|
||||
widget.library.globalKey,
|
||||
value,
|
||||
);
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
Navigator.pop(context);
|
||||
_loadItems();
|
||||
}
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showFiltersBottomSheet() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => FiltersBottomSheet(
|
||||
filters: _filters,
|
||||
selectedFilters: _selectedFilters,
|
||||
serverId: widget.library.serverId!,
|
||||
onFiltersChanged: (filters) async {
|
||||
setState(() {
|
||||
_selectedFilters.clear();
|
||||
_selectedFilters.addAll(filters);
|
||||
});
|
||||
|
||||
// Save filters to storage
|
||||
final storage = await StorageService.getInstance();
|
||||
await storage.saveLibraryFilters(
|
||||
filters,
|
||||
sectionId: widget.library.globalKey,
|
||||
);
|
||||
|
||||
_loadItems();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showSortBottomSheet() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => SortBottomSheet(
|
||||
sortOptions: _sortOptions,
|
||||
selectedSort: _selectedSort,
|
||||
isSortDescending: _isSortDescending,
|
||||
onSortChanged: (sort, descending) {
|
||||
setState(() {
|
||||
_selectedSort = sort;
|
||||
_isSortDescending = descending;
|
||||
});
|
||||
|
||||
StorageService.getInstance().then((storage) {
|
||||
storage.saveLibrarySort(
|
||||
widget.library.globalKey,
|
||||
sort.key,
|
||||
descending: descending,
|
||||
);
|
||||
});
|
||||
|
||||
_loadItems();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFilterChip({
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required VoidCallback onPressed,
|
||||
}) {
|
||||
return InkWell(
|
||||
onTap: onPressed,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Filter bar with chips
|
||||
Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
alignment: Alignment.centerLeft,
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Grouping chip
|
||||
_buildFilterChip(
|
||||
icon: Icons.category,
|
||||
label: _getGroupingLabel(_selectedGrouping),
|
||||
onPressed: _showGroupingBottomSheet,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Filters chip
|
||||
if (_filters.isNotEmpty && _selectedGrouping != 'folders')
|
||||
_buildFilterChip(
|
||||
icon: Icons.filter_alt,
|
||||
label: _selectedFilters.isEmpty
|
||||
? t.libraries.filters
|
||||
: t.libraries.filtersWithCount(
|
||||
count: _selectedFilters.length,
|
||||
),
|
||||
onPressed: _showFiltersBottomSheet,
|
||||
),
|
||||
if (_filters.isNotEmpty && _selectedGrouping != 'folders')
|
||||
const SizedBox(width: 8),
|
||||
// Sort chip
|
||||
if (_sortOptions.isNotEmpty && _selectedGrouping != 'folders')
|
||||
_buildFilterChip(
|
||||
icon: Icons.sort,
|
||||
label: _selectedSort?.title ?? t.libraries.sort,
|
||||
onPressed: _showSortBottomSheet,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Content
|
||||
Expanded(child: _buildContent()),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent() {
|
||||
// Show folder tree view when in folders mode
|
||||
if (_selectedGrouping == 'folders') {
|
||||
return FolderTreeView(
|
||||
libraryKey: widget.library.key,
|
||||
serverId: widget.library.serverId,
|
||||
onRefresh: updateItem,
|
||||
);
|
||||
}
|
||||
|
||||
if (_isLoading && _items.isEmpty) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (_errorMessage != null && _items.isEmpty) {
|
||||
return ErrorStateWidget(
|
||||
message: _errorMessage!,
|
||||
icon: Icons.error_outline,
|
||||
onRetry: _loadContent,
|
||||
retryLabel: t.common.retry,
|
||||
);
|
||||
}
|
||||
|
||||
if (_items.isEmpty) {
|
||||
return EmptyStateWidget(
|
||||
message: t.libraries.thisLibraryIsEmpty,
|
||||
icon: Icons.folder_open,
|
||||
);
|
||||
}
|
||||
|
||||
return NotificationListener<ScrollNotification>(
|
||||
onNotification: (notification) {
|
||||
if (notification.metrics.pixels >=
|
||||
notification.metrics.maxScrollExtent - 300 &&
|
||||
_hasMoreItems &&
|
||||
!_isLoading) {
|
||||
_loadItems(loadMore: true);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
child: Consumer<SettingsProvider>(
|
||||
builder: (context, settingsProvider, child) {
|
||||
if (settingsProvider.viewMode == ViewMode.list) {
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
|
||||
itemCount: _items.length + (_hasMoreItems && _isLoading ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (index >= _items.length) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
final item = _items[index];
|
||||
return MediaCard(
|
||||
key: Key(item.ratingKey),
|
||||
item: item,
|
||||
onRefresh: updateItem,
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return GridView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
|
||||
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent(
|
||||
context,
|
||||
settingsProvider.libraryDensity,
|
||||
),
|
||||
childAspectRatio: 2 / 3.3,
|
||||
crossAxisSpacing: 0,
|
||||
mainAxisSpacing: 0,
|
||||
),
|
||||
itemCount: _items.length + (_hasMoreItems && _isLoading ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (index >= _items.length) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
final item = _items[index];
|
||||
return MediaCard(
|
||||
key: Key(item.ratingKey),
|
||||
item: item,
|
||||
onRefresh: updateItem,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../models/plex_metadata.dart';
|
||||
import '../../utils/library_refresh_notifier.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
import '../../widgets/adaptive_media_grid.dart';
|
||||
import 'base_library_tab.dart';
|
||||
|
||||
/// Collections tab for library screen
|
||||
/// Shows collections for the current library
|
||||
class LibraryCollectionsTab extends BaseLibraryTab<PlexMetadata> {
|
||||
const LibraryCollectionsTab({
|
||||
super.key,
|
||||
required super.library,
|
||||
super.viewMode,
|
||||
super.density,
|
||||
});
|
||||
|
||||
@override
|
||||
State<LibraryCollectionsTab> createState() => _LibraryCollectionsTabState();
|
||||
}
|
||||
|
||||
class _LibraryCollectionsTabState
|
||||
extends BaseLibraryTabState<PlexMetadata, LibraryCollectionsTab> {
|
||||
@override
|
||||
IconData get emptyIcon => Icons.collections;
|
||||
|
||||
@override
|
||||
String get emptyMessage => t.libraries.noCollections;
|
||||
|
||||
@override
|
||||
String get errorContext => t.collections.title;
|
||||
|
||||
@override
|
||||
Stream<void>? getRefreshStream() =>
|
||||
LibraryRefreshNotifier().collectionsStream;
|
||||
|
||||
@override
|
||||
Future<List<PlexMetadata>> loadData() async {
|
||||
// Use server-specific client for this library
|
||||
final client = getClientForLibrary();
|
||||
|
||||
// Collections are automatically tagged with server info by PlexClient
|
||||
return await client.getLibraryCollections(widget.library.key);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget buildContent(List<PlexMetadata> items) {
|
||||
return AdaptiveMediaGrid(items: items, onRefresh: loadItems);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../models/plex_playlist.dart';
|
||||
import '../../providers/settings_provider.dart';
|
||||
import '../../utils/library_refresh_notifier.dart';
|
||||
import '../../services/settings_service.dart' show ViewMode;
|
||||
import '../../utils/grid_size_calculator.dart';
|
||||
import '../../widgets/media_card.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
import 'base_library_tab.dart';
|
||||
|
||||
/// Playlists tab for library screen
|
||||
/// Shows playlists that contain items from the current library
|
||||
class LibraryPlaylistsTab extends BaseLibraryTab<PlexPlaylist> {
|
||||
const LibraryPlaylistsTab({
|
||||
super.key,
|
||||
required super.library,
|
||||
super.viewMode,
|
||||
super.density,
|
||||
});
|
||||
|
||||
@override
|
||||
State<LibraryPlaylistsTab> createState() => _LibraryPlaylistsTabState();
|
||||
}
|
||||
|
||||
class _LibraryPlaylistsTabState
|
||||
extends BaseLibraryTabState<PlexPlaylist, LibraryPlaylistsTab> {
|
||||
@override
|
||||
IconData get emptyIcon => Icons.playlist_play;
|
||||
|
||||
@override
|
||||
String get emptyMessage => t.playlists.noPlaylists;
|
||||
|
||||
@override
|
||||
String get errorContext => t.playlists.title;
|
||||
|
||||
@override
|
||||
Stream<void>? getRefreshStream() => LibraryRefreshNotifier().playlistsStream;
|
||||
|
||||
@override
|
||||
Future<List<PlexPlaylist>> loadData() async {
|
||||
// Use server-specific client for this library
|
||||
final client = getClientForLibrary();
|
||||
|
||||
// Playlists are automatically tagged with server info by PlexClient
|
||||
return await client.getLibraryPlaylists(
|
||||
sectionId: widget.library.key,
|
||||
playlistType: 'video',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget buildContent(List<PlexPlaylist> items) {
|
||||
return Consumer<SettingsProvider>(
|
||||
builder: (context, settingsProvider, child) {
|
||||
if (settingsProvider.viewMode == ViewMode.list) {
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 8),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final playlist = items[index];
|
||||
return MediaCard(
|
||||
key: Key(playlist.ratingKey),
|
||||
item: playlist,
|
||||
onListRefresh: loadItems,
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return GridView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 8),
|
||||
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent(
|
||||
context,
|
||||
settingsProvider.libraryDensity,
|
||||
),
|
||||
childAspectRatio: 2 / 3.3,
|
||||
crossAxisSpacing: 0,
|
||||
mainAxisSpacing: 0,
|
||||
),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final playlist = items[index];
|
||||
return MediaCard(
|
||||
key: Key(playlist.ratingKey),
|
||||
item: playlist,
|
||||
onListRefresh: loadItems,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../models/plex_hub.dart';
|
||||
import '../../widgets/hub_section.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
import 'base_library_tab.dart';
|
||||
|
||||
/// Recommended tab for library screen
|
||||
/// Shows library-specific hubs and recommendations
|
||||
class LibraryRecommendedTab extends BaseLibraryTab<PlexHub> {
|
||||
const LibraryRecommendedTab({super.key, required super.library});
|
||||
|
||||
@override
|
||||
State<LibraryRecommendedTab> createState() => _LibraryRecommendedTabState();
|
||||
}
|
||||
|
||||
class _LibraryRecommendedTabState
|
||||
extends BaseLibraryTabState<PlexHub, LibraryRecommendedTab> {
|
||||
@override
|
||||
IconData get emptyIcon => Icons.recommend;
|
||||
|
||||
@override
|
||||
String get emptyMessage => t.libraries.noRecommendations;
|
||||
|
||||
@override
|
||||
String get errorContext => t.libraries.tabs.recommended;
|
||||
|
||||
@override
|
||||
Future<List<PlexHub>> loadData() async {
|
||||
// Use server-specific client for this library
|
||||
final client = getClientForLibrary();
|
||||
|
||||
// Hubs are now tagged with server info at the source
|
||||
return await client.getLibraryHubs(widget.library.key, limit: 12);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget buildContent(List<PlexHub> items) {
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final hub = items[index];
|
||||
return HubSection(hub: hub, icon: _getHubIcon(hub));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
IconData _getHubIcon(PlexHub hub) {
|
||||
final title = hub.title.toLowerCase();
|
||||
if (title.contains('continue watching') || title.contains('on deck')) {
|
||||
return Icons.play_circle;
|
||||
} else if (title.contains('recently') || title.contains('new')) {
|
||||
return Icons.fiber_new;
|
||||
} else if (title.contains('popular') || title.contains('trending')) {
|
||||
return Icons.trending_up;
|
||||
} else if (title.contains('top') || title.contains('rated')) {
|
||||
return Icons.star;
|
||||
} else if (title.contains('recommended')) {
|
||||
return Icons.thumb_up;
|
||||
} else if (title.contains('unwatched')) {
|
||||
return Icons.visibility_off;
|
||||
} else if (title.contains('genre')) {
|
||||
return Icons.category;
|
||||
}
|
||||
return Icons.movie;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../widgets/desktop_app_bar.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
|
||||
class MergedLicenseEntry {
|
||||
final String packageName;
|
||||
@@ -73,7 +74,7 @@ class _LicensesScreenState extends State<LicensesScreen> {
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
const CustomAppBar(title: Text('Licenses'), pinned: true),
|
||||
CustomAppBar(title: Text(t.screens.licenses), pinned: true),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
sliver: SliverList(
|
||||
@@ -92,7 +93,9 @@ class _LicensesScreenState extends State<LicensesScreen> {
|
||||
),
|
||||
subtitle: mergedLicense.licenseEntries.length > 1
|
||||
? Text(
|
||||
'${mergedLicense.licenseEntries.length} licenses',
|
||||
t.licenses.licensesCount(
|
||||
count: mergedLicense.licenseEntries.length,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
@@ -145,7 +148,7 @@ class _LicenseDetailScreen extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Related Packages',
|
||||
t.licenses.relatedPackages,
|
||||
style: Theme.of(context).textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
@@ -177,8 +180,10 @@ class _LicenseDetailScreen extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
isMultipleLicenses
|
||||
? 'License ${index + 1}'
|
||||
: 'License',
|
||||
? t.licenses.licenseNumber(
|
||||
number: index + 1,
|
||||
)
|
||||
: t.licenses.license,
|
||||
style: Theme.of(context).textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../widgets/desktop_app_bar.dart';
|
||||
|
||||
class LogsScreen extends StatefulWidget {
|
||||
const LogsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<LogsScreen> createState() => _LogsScreenState();
|
||||
}
|
||||
|
||||
class _LogsScreenState extends State<LogsScreen> {
|
||||
List<LogEntry> _logs = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadLogs();
|
||||
}
|
||||
|
||||
void _loadLogs() {
|
||||
setState(() {
|
||||
_logs = MemoryLogOutput.getLogs();
|
||||
});
|
||||
}
|
||||
|
||||
String _formatTime(DateTime time) {
|
||||
final hour = time.hour.toString().padLeft(2, '0');
|
||||
final minute = time.minute.toString().padLeft(2, '0');
|
||||
final second = time.second.toString().padLeft(2, '0');
|
||||
final millisecond = time.millisecond.toString().padLeft(3, '0');
|
||||
return '$hour:$minute:$second.$millisecond';
|
||||
}
|
||||
|
||||
void _clearLogs() {
|
||||
setState(() {
|
||||
MemoryLogOutput.clearLogs();
|
||||
_logs = [];
|
||||
});
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(t.messages.logsCleared)));
|
||||
}
|
||||
|
||||
void _copyAllLogs() {
|
||||
final buffer = StringBuffer();
|
||||
bool isFirst = true;
|
||||
for (final log in _logs.reversed) {
|
||||
if (!isFirst) {
|
||||
buffer.write('\n');
|
||||
}
|
||||
isFirst = false;
|
||||
|
||||
buffer.write(
|
||||
'[${_formatTime(log.timestamp)}] [${log.level.name.toUpperCase()}] ${log.message}',
|
||||
);
|
||||
if (log.error != null) {
|
||||
buffer.write('\nError: ${log.error}');
|
||||
}
|
||||
if (log.stackTrace != null) {
|
||||
buffer.write('\nStack trace:\n${log.stackTrace}');
|
||||
}
|
||||
}
|
||||
Clipboard.setData(ClipboardData(text: buffer.toString()));
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(t.messages.logsCopied)));
|
||||
}
|
||||
|
||||
Color _getLevelColor(Level level) {
|
||||
switch (level) {
|
||||
case Level.error:
|
||||
case Level.fatal:
|
||||
return Colors.red;
|
||||
case Level.warning:
|
||||
return Colors.orange;
|
||||
case Level.info:
|
||||
return Colors.blue;
|
||||
case Level.debug:
|
||||
case Level.trace:
|
||||
return Colors.grey;
|
||||
default:
|
||||
return Colors.grey;
|
||||
}
|
||||
}
|
||||
|
||||
IconData _getLevelIcon(Level level) {
|
||||
switch (level) {
|
||||
case Level.error:
|
||||
case Level.fatal:
|
||||
return Icons.error;
|
||||
case Level.warning:
|
||||
return Icons.warning;
|
||||
case Level.info:
|
||||
return Icons.info;
|
||||
case Level.debug:
|
||||
case Level.trace:
|
||||
return Icons.bug_report;
|
||||
default:
|
||||
return Icons.circle;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
CustomAppBar(
|
||||
title: Text(t.screens.logs),
|
||||
pinned: true,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: _loadLogs,
|
||||
tooltip: t.common.refresh,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy),
|
||||
onPressed: _logs.isNotEmpty ? _copyAllLogs : null,
|
||||
tooltip: t.logs.copyLogs,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
onPressed: _logs.isNotEmpty ? _clearLogs : null,
|
||||
tooltip: t.logs.clearLogs,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_logs.isEmpty)
|
||||
SliverFillRemaining(
|
||||
child: Center(child: Text(t.messages.noLogsAvailable)),
|
||||
)
|
||||
else
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final log = _logs[index];
|
||||
return _LogEntryCard(
|
||||
log: log,
|
||||
formatTime: _formatTime,
|
||||
levelColor: _getLevelColor(log.level),
|
||||
levelIcon: _getLevelIcon(log.level),
|
||||
);
|
||||
}, childCount: _logs.length),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LogEntryCard extends StatefulWidget {
|
||||
final LogEntry log;
|
||||
final String Function(DateTime) formatTime;
|
||||
final Color levelColor;
|
||||
final IconData levelIcon;
|
||||
|
||||
const _LogEntryCard({
|
||||
required this.log,
|
||||
required this.formatTime,
|
||||
required this.levelColor,
|
||||
required this.levelIcon,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_LogEntryCard> createState() => _LogEntryCardState();
|
||||
}
|
||||
|
||||
class _LogEntryCardState extends State<_LogEntryCard> {
|
||||
bool _isExpanded = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasErrorOrStackTrace =
|
||||
widget.log.error != null || widget.log.stackTrace != null;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 0),
|
||||
child: InkWell(
|
||||
onTap: hasErrorOrStackTrace
|
||||
? () => setState(() => _isExpanded = !_isExpanded)
|
||||
: null,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(widget.levelIcon, color: widget.levelColor, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
widget.log.level.name.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: widget.levelColor,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
widget.formatTime(widget.log.timestamp),
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.color
|
||||
?.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
widget.log.message,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (hasErrorOrStackTrace)
|
||||
Icon(
|
||||
_isExpanded ? Icons.expand_less : Icons.expand_more,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).iconTheme.color?.withValues(alpha: 0.6),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_isExpanded && hasErrorOrStackTrace) ...[
|
||||
const SizedBox(height: 12),
|
||||
const Divider(),
|
||||
const SizedBox(height: 8),
|
||||
if (widget.log.error != null) ...[
|
||||
Text(
|
||||
t.logs.error,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: widget.levelColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).brightness == Brightness.dark
|
||||
? Colors.grey[900]
|
||||
: Colors.grey[200],
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: SelectableText(
|
||||
widget.log.error.toString(),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(fontFamily: 'monospace'),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (widget.log.stackTrace != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
t.logs.stackTrace,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: widget.levelColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).brightness == Brightness.dark
|
||||
? Colors.grey[900]
|
||||
: Colors.grey[200],
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: SelectableText(
|
||||
widget.log.stackTrace.toString(),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(fontFamily: 'monospace'),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,17 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../client/plex_client.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
import '../main.dart';
|
||||
import '../mixins/refreshable.dart';
|
||||
import '../providers/multi_server_provider.dart';
|
||||
import '../providers/server_state_provider.dart';
|
||||
import '../providers/hidden_libraries_provider.dart';
|
||||
import '../providers/playback_state_provider.dart';
|
||||
import '../services/plex_auth_service.dart';
|
||||
import '../services/storage_service.dart';
|
||||
import 'discover_screen.dart';
|
||||
import 'libraries_screen.dart';
|
||||
import 'search_screen.dart';
|
||||
@@ -90,25 +98,55 @@ class _MainScreenState extends State<MainScreen> with RouteAware {
|
||||
}
|
||||
|
||||
/// Invalidate all cached data across all screens when profile is switched
|
||||
void _invalidateAllScreens() {
|
||||
appLogger.d('Invalidating all screen data due to profile switch');
|
||||
/// Receives the list of servers with new profile tokens for reconnection
|
||||
Future<void> _invalidateAllScreens(List<PlexServer> servers) async {
|
||||
appLogger.d(
|
||||
'Invalidating all screen data due to profile switch with ${servers.length} servers',
|
||||
);
|
||||
|
||||
// Refresh discover screen
|
||||
// Get all providers
|
||||
final multiServerProvider = context.read<MultiServerProvider>();
|
||||
final serverStateProvider = context.read<ServerStateProvider>();
|
||||
final hiddenLibrariesProvider = context.read<HiddenLibrariesProvider>();
|
||||
final playbackStateProvider = context.read<PlaybackStateProvider>();
|
||||
|
||||
// Reconnect to all servers with new profile tokens
|
||||
if (servers.isNotEmpty) {
|
||||
final storage = await StorageService.getInstance();
|
||||
final clientId = storage.getClientIdentifier();
|
||||
|
||||
final connectedCount = await multiServerProvider.reconnectWithServers(
|
||||
servers,
|
||||
clientIdentifier: clientId,
|
||||
);
|
||||
appLogger.d(
|
||||
'Reconnected to $connectedCount/${servers.length} servers after profile switch',
|
||||
);
|
||||
}
|
||||
|
||||
// Reset other provider states
|
||||
serverStateProvider.reset();
|
||||
hiddenLibrariesProvider.refresh();
|
||||
playbackStateProvider.clearShuffle();
|
||||
|
||||
appLogger.d('Cleared all provider states for profile switch');
|
||||
|
||||
// Full refresh discover screen (reload all content for new profile)
|
||||
final discoverState = _discoverKey.currentState;
|
||||
if (discoverState != null && discoverState is Refreshable) {
|
||||
(discoverState as Refreshable).refresh();
|
||||
if (discoverState != null) {
|
||||
(discoverState as dynamic).fullRefresh();
|
||||
}
|
||||
|
||||
// Refresh libraries screen
|
||||
// Full refresh libraries screen (clear filters and reload for new profile)
|
||||
final librariesState = _librariesKey.currentState;
|
||||
if (librariesState != null && librariesState is Refreshable) {
|
||||
(librariesState as Refreshable).refresh();
|
||||
if (librariesState != null) {
|
||||
(librariesState as dynamic).fullRefresh();
|
||||
}
|
||||
|
||||
// Refresh search screen
|
||||
// Full refresh search screen (clear search for new profile)
|
||||
final searchState = _searchKey.currentState;
|
||||
if (searchState != null && searchState is Refreshable) {
|
||||
(searchState as Refreshable).refresh();
|
||||
if (searchState != null) {
|
||||
(searchState as dynamic).fullRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,26 +165,26 @@ class _MainScreenState extends State<MainScreen> with RouteAware {
|
||||
_onDiscoverBecameVisible();
|
||||
}
|
||||
},
|
||||
destinations: const [
|
||||
destinations: [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.home_outlined),
|
||||
selectedIcon: Icon(Icons.home),
|
||||
label: 'Home',
|
||||
icon: const Icon(Icons.home_outlined),
|
||||
selectedIcon: const Icon(Icons.home),
|
||||
label: t.navigation.home,
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.video_library_outlined),
|
||||
selectedIcon: Icon(Icons.video_library),
|
||||
label: 'Libraries',
|
||||
icon: const Icon(Icons.video_library_outlined),
|
||||
selectedIcon: const Icon(Icons.video_library),
|
||||
label: t.navigation.libraries,
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.search),
|
||||
selectedIcon: Icon(Icons.search),
|
||||
label: 'Search',
|
||||
icon: const Icon(Icons.search),
|
||||
selectedIcon: const Icon(Icons.search),
|
||||
label: t.navigation.search,
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.settings_outlined),
|
||||
selectedIcon: Icon(Icons.settings),
|
||||
label: 'Settings',
|
||||
icon: const Icon(Icons.settings_outlined),
|
||||
selectedIcon: const Icon(Icons.settings),
|
||||
label: t.navigation.settings,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../client/plex_client.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../providers/plex_client_provider.dart';
|
||||
import '../widgets/desktop_app_bar.dart';
|
||||
import '../widgets/app_bar_back_button.dart';
|
||||
import '../widgets/media_context_menu.dart';
|
||||
import '../providers/playback_state_provider.dart';
|
||||
import '../theme/theme_helper.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/content_rating_formatter.dart';
|
||||
import '../utils/duration_formatter.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
import '../utils/video_player_navigation.dart';
|
||||
import '../theme/theme_helper.dart';
|
||||
import '../widgets/app_bar_back_button.dart';
|
||||
import '../widgets/desktop_app_bar.dart';
|
||||
import '../widgets/horizontal_scroll_with_arrows.dart';
|
||||
import '../widgets/media_context_menu.dart';
|
||||
import 'season_detail_screen.dart';
|
||||
|
||||
class MediaDetailScreen extends StatefulWidget {
|
||||
@@ -43,17 +49,19 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Get the correct PlexClient for this metadata's server
|
||||
PlexClient _getClientForMetadata(BuildContext context) {
|
||||
return context.getClientForServer(widget.metadata.serverId!);
|
||||
}
|
||||
|
||||
Future<void> _loadFullMetadata() async {
|
||||
setState(() {
|
||||
_isLoadingMetadata = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final clientProvider = context.plexClient;
|
||||
final client = clientProvider.client;
|
||||
if (client == null) {
|
||||
throw Exception('No client available');
|
||||
}
|
||||
// Use server-specific client for this metadata
|
||||
final client = _getClientForMetadata(context);
|
||||
|
||||
// Fetch full metadata with clearLogo and OnDeck episode
|
||||
final result = await client.getMetadataWithImagesAndOnDeck(
|
||||
@@ -63,9 +71,19 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
final onDeckEpisode = result['onDeckEpisode'] as PlexMetadata?;
|
||||
|
||||
if (metadata != null) {
|
||||
// Preserve serverId from original metadata
|
||||
final metadataWithServerId = metadata.copyWith(
|
||||
serverId: widget.metadata.serverId,
|
||||
serverName: widget.metadata.serverName,
|
||||
);
|
||||
final onDeckWithServerId = onDeckEpisode?.copyWith(
|
||||
serverId: widget.metadata.serverId,
|
||||
serverName: widget.metadata.serverName,
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_fullMetadata = metadata;
|
||||
_onDeckEpisode = onDeckEpisode;
|
||||
_fullMetadata = metadataWithServerId;
|
||||
_onDeckEpisode = onDeckWithServerId;
|
||||
_isLoadingMetadata = false;
|
||||
});
|
||||
|
||||
@@ -104,15 +122,21 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
});
|
||||
|
||||
try {
|
||||
final clientProvider = context.plexClient;
|
||||
final client = clientProvider.client;
|
||||
if (client == null) {
|
||||
throw Exception('No client available');
|
||||
}
|
||||
// Use server-specific client for this metadata
|
||||
final client = _getClientForMetadata(context);
|
||||
|
||||
final seasons = await client.getChildren(widget.metadata.ratingKey);
|
||||
// Preserve serverId for each season
|
||||
final seasonsWithServerId = seasons
|
||||
.map(
|
||||
(season) => season.copyWith(
|
||||
serverId: widget.metadata.serverId,
|
||||
serverName: widget.metadata.serverName,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
setState(() {
|
||||
_seasons = seasons;
|
||||
_seasons = seasonsWithServerId;
|
||||
_isLoadingSeasons = false;
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -126,26 +150,38 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
/// This preserves scroll position and only updates watch-related data
|
||||
Future<void> _updateWatchState() async {
|
||||
try {
|
||||
final clientProvider = context.plexClient;
|
||||
final client = clientProvider.client;
|
||||
if (client == null) {
|
||||
throw Exception('No client available');
|
||||
}
|
||||
// Use server-specific client for this metadata
|
||||
final client = _getClientForMetadata(context);
|
||||
|
||||
final metadata = await client.getMetadataWithImages(
|
||||
widget.metadata.ratingKey,
|
||||
);
|
||||
|
||||
if (metadata != null) {
|
||||
// Preserve serverId from original metadata
|
||||
final metadataWithServerId = metadata.copyWith(
|
||||
serverId: widget.metadata.serverId,
|
||||
serverName: widget.metadata.serverName,
|
||||
);
|
||||
|
||||
// For shows, also refetch seasons to update their watch counts
|
||||
List<PlexMetadata>? updatedSeasons;
|
||||
if (metadata.type.toLowerCase() == 'show') {
|
||||
updatedSeasons = await client.getChildren(widget.metadata.ratingKey);
|
||||
final seasons = await client.getChildren(widget.metadata.ratingKey);
|
||||
// Preserve serverId for each season
|
||||
updatedSeasons = seasons
|
||||
.map(
|
||||
(season) => season.copyWith(
|
||||
serverId: widget.metadata.serverId,
|
||||
serverName: widget.metadata.serverName,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Single setState to minimize rebuilds - scroll position is preserved by controller
|
||||
setState(() {
|
||||
_fullMetadata = metadata;
|
||||
_fullMetadata = metadataWithServerId;
|
||||
if (updatedSeasons != null) {
|
||||
_seasons = updatedSeasons;
|
||||
}
|
||||
@@ -159,12 +195,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
|
||||
Future<void> _playFirstEpisode() async {
|
||||
try {
|
||||
// Extract context dependencies before async operations
|
||||
final clientProvider = context.plexClient;
|
||||
final client = clientProvider.client;
|
||||
if (client == null) {
|
||||
throw Exception('No client available');
|
||||
}
|
||||
// Use server-specific client for this metadata
|
||||
final client = _getClientForMetadata(context);
|
||||
|
||||
// If seasons aren't loaded yet, wait for them or load them
|
||||
if (_seasons.isEmpty && !_isLoadingSeasons) {
|
||||
@@ -180,7 +212,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('No seasons found')));
|
||||
).showSnackBar(SnackBar(content: Text(t.messages.noSeasonsFound)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -193,22 +225,24 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
|
||||
if (episodes.isEmpty) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('No episodes found in first season')),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(t.messages.noEpisodesFound)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Play the first episode
|
||||
final firstEpisode = episodes.first;
|
||||
// Preserve serverId for the episode
|
||||
final episodeWithServerId = firstEpisode.copyWith(
|
||||
serverId: widget.metadata.serverId,
|
||||
serverName: widget.metadata.serverName,
|
||||
);
|
||||
if (mounted) {
|
||||
final clientProvider = context.plexClient;
|
||||
final client = clientProvider.client;
|
||||
if (client == null) return;
|
||||
|
||||
appLogger.d('Playing first episode: ${firstEpisode.title}');
|
||||
await navigateToVideoPlayer(context, metadata: firstEpisode);
|
||||
// Client already retrieved earlier in the method
|
||||
appLogger.d('Playing first episode: ${episodeWithServerId.title}');
|
||||
await navigateToVideoPlayer(context, metadata: episodeWithServerId);
|
||||
appLogger.d('Returned from playback, refreshing metadata');
|
||||
// Refresh metadata when returning from video player
|
||||
_loadFullMetadata();
|
||||
@@ -216,7 +250,101 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error loading first episode: $e')),
|
||||
SnackBar(content: Text(t.messages.errorLoading(error: e.toString()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle shuffle play using play queues
|
||||
Future<void> _handleShufflePlayWithQueue(
|
||||
BuildContext context,
|
||||
PlexMetadata metadata,
|
||||
) async {
|
||||
final client = _getClientForMetadata(context);
|
||||
|
||||
final playbackState = context.read<PlaybackStateProvider>();
|
||||
final itemType = metadata.type.toLowerCase();
|
||||
|
||||
try {
|
||||
// Show loading indicator
|
||||
if (context.mounted) {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) =>
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
// Determine the rating key for the play queue
|
||||
String showRatingKey;
|
||||
if (itemType == 'show') {
|
||||
showRatingKey = metadata.ratingKey;
|
||||
} else if (itemType == 'season') {
|
||||
// For seasons, we need the show's rating key
|
||||
// The season's parentRatingKey should point to the show
|
||||
if (metadata.parentRatingKey == null) {
|
||||
throw Exception('Season is missing parentRatingKey');
|
||||
}
|
||||
showRatingKey = metadata.parentRatingKey!;
|
||||
} else {
|
||||
throw Exception('Shuffle play only works for shows and seasons');
|
||||
}
|
||||
|
||||
// Create a shuffled play queue for the show
|
||||
final playQueue = await client.createShowPlayQueue(
|
||||
showRatingKey: showRatingKey,
|
||||
shuffle: 1,
|
||||
);
|
||||
|
||||
// Close loading indicator
|
||||
if (context.mounted) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
if (playQueue == null ||
|
||||
playQueue.items == null ||
|
||||
playQueue.items!.isEmpty) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(t.messages.noEpisodesFound)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize playback state with the play queue
|
||||
await playbackState.setPlaybackFromPlayQueue(
|
||||
playQueue,
|
||||
showRatingKey,
|
||||
serverId: metadata.serverId,
|
||||
serverName: metadata.serverName,
|
||||
);
|
||||
|
||||
// Set the client for the playback state provider
|
||||
playbackState.setClient(client);
|
||||
|
||||
// Navigate to the first episode in the shuffled queue
|
||||
final firstEpisode = playQueue.items!.first.copyWith(
|
||||
serverId: metadata.serverId,
|
||||
serverName: metadata.serverName,
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
await navigateToVideoPlayer(context, metadata: firstEpisode);
|
||||
// Refresh metadata when returning from video player
|
||||
_loadFullMetadata();
|
||||
}
|
||||
} catch (e) {
|
||||
// Close loading indicator if it's still open
|
||||
if (context.mounted && Navigator.canPop(context)) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(t.messages.errorLoading(error: e.toString()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -259,16 +387,9 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
children: [
|
||||
// Background Art
|
||||
if (metadata.art != null)
|
||||
Consumer<PlexClientProvider>(
|
||||
builder: (context, clientProvider, child) {
|
||||
final client = clientProvider.client;
|
||||
if (client == null) {
|
||||
return Container(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
);
|
||||
}
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final client = _getClientForMetadata(context);
|
||||
return CachedNetworkImage(
|
||||
imageUrl: client.getThumbnailUrl(metadata.art),
|
||||
fit: BoxFit.cover,
|
||||
@@ -325,21 +446,11 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
SizedBox(
|
||||
height: 120,
|
||||
width: 400,
|
||||
child: Consumer<PlexClientProvider>(
|
||||
builder: (context, clientProvider, child) {
|
||||
final client = clientProvider.client;
|
||||
if (client == null) {
|
||||
return Text(
|
||||
metadata.title,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.displaySmall
|
||||
?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
);
|
||||
}
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final client = _getClientForMetadata(
|
||||
context,
|
||||
);
|
||||
return CachedNetworkImage(
|
||||
imageUrl: client.getThumbnailUrl(
|
||||
metadata.clearLogo,
|
||||
@@ -461,7 +572,9 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
metadata.contentRating!,
|
||||
formatContentRating(
|
||||
metadata.contentRating!,
|
||||
),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
@@ -482,7 +595,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
_formatDuration(metadata.duration!),
|
||||
formatDurationTextual(metadata.duration!),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
@@ -490,6 +603,70 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (metadata.rating != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(
|
||||
alpha: 0.4,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.star,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${(metadata.rating! * 10).toStringAsFixed(0)}%',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (metadata.audienceRating != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(
|
||||
alpha: 0.4,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.people,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${(metadata.audienceRating! * 10).toStringAsFixed(0)}%',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -521,10 +698,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
// Otherwise, play the first episode of the first season
|
||||
if (metadata.type.toLowerCase() == 'show') {
|
||||
if (_onDeckEpisode != null) {
|
||||
final clientProvider = context.plexClient;
|
||||
final client = clientProvider.client;
|
||||
if (client == null) return;
|
||||
|
||||
appLogger.d(
|
||||
'Playing on deck episode: ${_onDeckEpisode!.title}',
|
||||
);
|
||||
@@ -542,10 +715,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
await _playFirstEpisode();
|
||||
}
|
||||
} else {
|
||||
final clientProvider = context.plexClient;
|
||||
final client = clientProvider.client;
|
||||
if (client == null) return;
|
||||
|
||||
appLogger.d('Playing: ${metadata.title}');
|
||||
// For movies or episodes, play directly
|
||||
await navigateToVideoPlayer(
|
||||
@@ -573,19 +742,37 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Shuffle button (only for shows and seasons)
|
||||
if (metadata.type.toLowerCase() == 'show' ||
|
||||
metadata.type.toLowerCase() == 'season') ...[
|
||||
IconButton.filledTonal(
|
||||
onPressed: () async {
|
||||
await _handleShufflePlayWithQueue(
|
||||
context,
|
||||
metadata,
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.shuffle),
|
||||
tooltip: t.tooltips.shufflePlay,
|
||||
iconSize: 20,
|
||||
style: IconButton.styleFrom(
|
||||
minimumSize: const Size(48, 48),
|
||||
maximumSize: const Size(48, 48),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
IconButton.filledTonal(
|
||||
onPressed: () async {
|
||||
try {
|
||||
final clientProvider = context.plexClient;
|
||||
final client = clientProvider.client;
|
||||
if (client == null) return;
|
||||
final client = _getClientForMetadata(context);
|
||||
|
||||
await client.markAsWatched(metadata.ratingKey);
|
||||
if (context.mounted) {
|
||||
_watchStateChanged = true;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Marked as watched'),
|
||||
SnackBar(
|
||||
content: Text(t.messages.markedAsWatched),
|
||||
),
|
||||
);
|
||||
// Update watch state without full rebuild
|
||||
@@ -594,13 +781,19 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error: $e')),
|
||||
SnackBar(
|
||||
content: Text(
|
||||
t.messages.errorLoading(
|
||||
error: e.toString(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.check),
|
||||
tooltip: 'Mark as watched',
|
||||
tooltip: t.tooltips.markAsWatched,
|
||||
iconSize: 20,
|
||||
style: IconButton.styleFrom(
|
||||
minimumSize: const Size(48, 48),
|
||||
@@ -611,16 +804,14 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
IconButton.filledTonal(
|
||||
onPressed: () async {
|
||||
try {
|
||||
final clientProvider = context.plexClient;
|
||||
final client = clientProvider.client;
|
||||
if (client == null) return;
|
||||
final client = _getClientForMetadata(context);
|
||||
|
||||
await client.markAsUnwatched(metadata.ratingKey);
|
||||
if (context.mounted) {
|
||||
_watchStateChanged = true;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Marked as unwatched'),
|
||||
SnackBar(
|
||||
content: Text(t.messages.markedAsUnwatched),
|
||||
),
|
||||
);
|
||||
// Update watch state without full rebuild
|
||||
@@ -629,13 +820,19 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error: $e')),
|
||||
SnackBar(
|
||||
content: Text(
|
||||
t.messages.errorLoading(
|
||||
error: e.toString(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.remove_done),
|
||||
tooltip: 'Mark as unwatched',
|
||||
tooltip: t.tooltips.markAsUnwatched,
|
||||
iconSize: 20,
|
||||
style: IconButton.styleFrom(
|
||||
minimumSize: const Size(48, 48),
|
||||
@@ -650,7 +847,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
// Summary
|
||||
if (metadata.summary != null) ...[
|
||||
Text(
|
||||
'Overview',
|
||||
t.discover.overview,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
@@ -668,7 +865,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
// Seasons (for TV shows)
|
||||
if (isShow) ...[
|
||||
Text(
|
||||
'Seasons',
|
||||
t.discover.seasons,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
@@ -686,7 +883,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'No seasons found',
|
||||
t.messages.noSeasonsFound,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyLarge?.copyWith(color: Colors.grey),
|
||||
@@ -709,13 +906,135 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
|
||||
// Cast
|
||||
if (metadata.role != null && metadata.role!.isNotEmpty) ...[
|
||||
Text(
|
||||
t.discover.cast,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
height: 220,
|
||||
child: HorizontalScrollWithArrows(
|
||||
builder: (scrollController) => ListView.separated(
|
||||
controller: scrollController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
itemCount: metadata.role!.length,
|
||||
separatorBuilder: (context, index) =>
|
||||
const SizedBox(width: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final actor = metadata.role![index];
|
||||
return SizedBox(
|
||||
width: 120,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: actor.thumb != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: actor.thumb!,
|
||||
width: 120,
|
||||
height: 120,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) =>
|
||||
Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHighest,
|
||||
child: const Center(
|
||||
child: Icon(Icons.person),
|
||||
),
|
||||
),
|
||||
errorWidget:
|
||||
(
|
||||
context,
|
||||
url,
|
||||
error,
|
||||
) => Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHighest,
|
||||
child: const Center(
|
||||
child: Icon(Icons.person),
|
||||
),
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHighest,
|
||||
child: const Center(
|
||||
child: Icon(Icons.person),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: 84,
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
actor.tag,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (actor.role != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
actor.role!,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
|
||||
// Additional info
|
||||
if (metadata.studio != null) ...[
|
||||
_buildInfoRow('Studio', metadata.studio!),
|
||||
_buildInfoRow(t.discover.studio, metadata.studio!),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
if (metadata.contentRating != null) ...[
|
||||
_buildInfoRow('Rating', metadata.contentRating!),
|
||||
_buildInfoRow(
|
||||
t.discover.rating,
|
||||
formatContentRating(metadata.contentRating!),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
],
|
||||
@@ -731,7 +1050,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
return Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: MediaContextMenu(
|
||||
metadata: season,
|
||||
item: season,
|
||||
onRefresh: (ratingKey) {
|
||||
_watchStateChanged = true;
|
||||
_updateWatchState();
|
||||
@@ -762,19 +1081,9 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
if (season.thumb != null)
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Consumer<PlexClientProvider>(
|
||||
builder: (context, clientProvider, child) {
|
||||
final client = clientProvider.client;
|
||||
if (client == null) {
|
||||
return Container(
|
||||
width: 80,
|
||||
height: 120,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
child: const Icon(Icons.movie, size: 32),
|
||||
);
|
||||
}
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final client = _getClientForMetadata(context);
|
||||
return CachedNetworkImage(
|
||||
imageUrl: client.getThumbnailUrl(season.thumb),
|
||||
width: 80,
|
||||
@@ -826,7 +1135,9 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
const SizedBox(height: 4),
|
||||
if (season.leafCount != null)
|
||||
Text(
|
||||
'${season.leafCount} episodes',
|
||||
t.discover.episodeCount(
|
||||
count: season.leafCount.toString(),
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodyMedium
|
||||
?.copyWith(color: Colors.grey),
|
||||
),
|
||||
@@ -854,7 +1165,10 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${season.viewedLeafCount}/${season.leafCount} watched',
|
||||
t.discover.watchedProgress(
|
||||
watched: season.viewedLeafCount.toString(),
|
||||
total: season.leafCount.toString(),
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(color: Colors.grey),
|
||||
),
|
||||
@@ -895,18 +1209,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDuration(int milliseconds) {
|
||||
final duration = Duration(milliseconds: milliseconds);
|
||||
final hours = duration.inHours;
|
||||
final minutes = duration.inMinutes.remainder(60);
|
||||
|
||||
if (hours > 0) {
|
||||
return '${hours}h ${minutes}m';
|
||||
} else {
|
||||
return '${minutes}m';
|
||||
}
|
||||
}
|
||||
|
||||
String _getPlayButtonLabel(PlexMetadata metadata) {
|
||||
// For TV shows
|
||||
if (metadata.type.toLowerCase() == 'show') {
|
||||
@@ -917,21 +1219,27 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
|
||||
// Check if episode has been partially watched (viewOffset > 0)
|
||||
if (episode.viewOffset != null && episode.viewOffset! > 0) {
|
||||
return 'Resume S$seasonNum, E$episodeNum';
|
||||
return t.discover.resumeEpisode(
|
||||
season: seasonNum.toString(),
|
||||
episode: episodeNum.toString(),
|
||||
);
|
||||
} else {
|
||||
return 'Play S$seasonNum, E$episodeNum';
|
||||
return t.discover.playEpisode(
|
||||
season: seasonNum.toString(),
|
||||
episode: episodeNum.toString(),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// No on deck episode, will play first episode
|
||||
return 'Play S1, E1';
|
||||
return t.discover.playEpisode(season: '1', episode: '1');
|
||||
}
|
||||
}
|
||||
|
||||
// For movies or episodes, check if partially watched
|
||||
if (metadata.viewOffset != null && metadata.viewOffset! > 0) {
|
||||
return 'Resume';
|
||||
return t.discover.resume;
|
||||
}
|
||||
|
||||
return 'Play';
|
||||
return t.discover.play;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../client/plex_client.dart';
|
||||
import '../models/plex_playlist.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
import '../providers/playback_state_provider.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
import '../utils/video_player_navigation.dart';
|
||||
import '../utils/grid_size_calculator.dart';
|
||||
import '../widgets/media_card.dart';
|
||||
import '../widgets/playlist_item_card.dart';
|
||||
import '../widgets/desktop_app_bar.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../utils/dialogs.dart';
|
||||
import 'base_media_list_detail_screen.dart';
|
||||
|
||||
/// Screen to display the contents of a playlist
|
||||
class PlaylistDetailScreen extends StatefulWidget {
|
||||
final PlexPlaylist playlist;
|
||||
|
||||
const PlaylistDetailScreen({super.key, required this.playlist});
|
||||
|
||||
@override
|
||||
State<PlaylistDetailScreen> createState() => _PlaylistDetailScreenState();
|
||||
}
|
||||
|
||||
class _PlaylistDetailScreenState
|
||||
extends BaseMediaListDetailScreen<PlaylistDetailScreen>
|
||||
with StandardItemLoader<PlaylistDetailScreen> {
|
||||
@override
|
||||
dynamic get mediaItem => widget.playlist;
|
||||
|
||||
@override
|
||||
String get title => widget.playlist.title;
|
||||
|
||||
@override
|
||||
String get emptyMessage => t.playlists.emptyPlaylist;
|
||||
|
||||
@override
|
||||
IconData get emptyIcon => Icons.playlist_play;
|
||||
|
||||
@override
|
||||
Future<List<PlexMetadata>> fetchItems() async {
|
||||
return await client.getPlaylist(widget.playlist.ratingKey);
|
||||
}
|
||||
|
||||
@override
|
||||
String getLoadSuccessMessage(int itemCount) {
|
||||
return 'Loaded $itemCount items for playlist: ${widget.playlist.title}';
|
||||
}
|
||||
|
||||
/// Get the correct PlexClient for this playlist's server
|
||||
PlexClient _getClientForPlaylist() {
|
||||
return context.getClientForServer(widget.playlist.serverId!);
|
||||
}
|
||||
|
||||
Future<void> _deletePlaylist() async {
|
||||
final confirmed = await showDeleteConfirmation(
|
||||
context,
|
||||
title: t.playlists.deleteConfirm,
|
||||
message: t.playlists.deleteMessage(name: widget.playlist.title),
|
||||
);
|
||||
|
||||
if (confirmed == true && mounted) {
|
||||
final success = await client.deletePlaylist(widget.playlist.ratingKey);
|
||||
|
||||
if (mounted) {
|
||||
if (success) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(t.playlists.deleted)));
|
||||
Navigator.pop(context); // Return to playlists screen
|
||||
} else {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(t.playlists.errorDeleting)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onReorder(int oldIndex, int newIndex) async {
|
||||
// Adjust newIndex if moving down in the list
|
||||
if (newIndex > oldIndex) {
|
||||
newIndex--;
|
||||
}
|
||||
|
||||
// Can't reorder if indices are the same
|
||||
if (oldIndex == newIndex) return;
|
||||
|
||||
final movedItem = items[oldIndex];
|
||||
|
||||
// Check if item has playlistItemID (required for reordering)
|
||||
if (movedItem.playlistItemID == null) {
|
||||
appLogger.e('Cannot reorder: item missing playlistItemID');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(t.playlists.errorReordering)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine the "after" item ID
|
||||
// If moving to position 0, afterPlaylistItemId should be 0 (move to top)
|
||||
// Otherwise, use the playlistItemID of the item before the new position
|
||||
final int afterPlaylistItemId;
|
||||
if (newIndex == 0) {
|
||||
afterPlaylistItemId = 0; // Move to top
|
||||
} else {
|
||||
final afterItem = items[newIndex - 1];
|
||||
if (afterItem.playlistItemID == null) {
|
||||
appLogger.e('Cannot reorder: after item missing playlistItemID');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(t.playlists.errorReordering)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
afterPlaylistItemId = afterItem.playlistItemID!;
|
||||
}
|
||||
|
||||
appLogger.d(
|
||||
'Reordering item from $oldIndex to $newIndex (after ID: $afterPlaylistItemId)',
|
||||
);
|
||||
|
||||
// Optimistically update UI
|
||||
setState(() {
|
||||
final item = items.removeAt(oldIndex);
|
||||
items.insert(newIndex, item);
|
||||
});
|
||||
|
||||
// Call API to persist the change
|
||||
final success = await client.movePlaylistItem(
|
||||
playlistId: widget.playlist.ratingKey,
|
||||
playlistItemId: movedItem.playlistItemID!,
|
||||
afterPlaylistItemId: afterPlaylistItemId,
|
||||
);
|
||||
|
||||
if (!success) {
|
||||
// Revert on failure
|
||||
appLogger.e('Failed to reorder playlist item, reverting UI');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
final item = items.removeAt(newIndex);
|
||||
items.insert(oldIndex, item);
|
||||
});
|
||||
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(t.playlists.errorReordering)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _removeItem(int index) async {
|
||||
final item = items[index];
|
||||
|
||||
// Check if item has playlistItemID (required for removal)
|
||||
if (item.playlistItemID == null) {
|
||||
appLogger.e('Cannot remove: item missing playlistItemID');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(t.playlists.errorRemoving)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
appLogger.d(
|
||||
'Removing item ${item.title} (playlistItemID: ${item.playlistItemID}) from playlist',
|
||||
);
|
||||
|
||||
// Optimistically update UI
|
||||
setState(() {
|
||||
items.removeAt(index);
|
||||
});
|
||||
|
||||
// Call API to persist the change
|
||||
final success = await client.removeFromPlaylist(
|
||||
playlistId: widget.playlist.ratingKey,
|
||||
playlistItemId: item.playlistItemID.toString(),
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
if (success) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(t.playlists.itemRemoved)));
|
||||
} else {
|
||||
// Revert on failure
|
||||
appLogger.e('Failed to remove playlist item, reverting UI');
|
||||
setState(() {
|
||||
items.insert(index, item);
|
||||
});
|
||||
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(t.playlists.errorRemoving)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _playFromItem(int index) async {
|
||||
if (items.isEmpty || index < 0 || index >= items.length) return;
|
||||
|
||||
try {
|
||||
final client = _getClientForPlaylist();
|
||||
|
||||
final selectedItem = items[index];
|
||||
|
||||
// Create play queue from playlist, starting at the selected item
|
||||
final playQueue = await client.createPlayQueue(
|
||||
playlistID: int.parse(widget.playlist.ratingKey),
|
||||
type: 'video',
|
||||
key: selectedItem.key,
|
||||
);
|
||||
|
||||
if (playQueue == null ||
|
||||
playQueue.items == null ||
|
||||
playQueue.items!.isEmpty) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(t.messages.failedToCreatePlayQueue)),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
// Set play queue in provider
|
||||
final playbackState = context.read<PlaybackStateProvider>();
|
||||
playbackState.setClient(client);
|
||||
await playbackState.setPlaybackFromPlayQueue(
|
||||
playQueue,
|
||||
widget.playlist.ratingKey,
|
||||
);
|
||||
|
||||
// Navigate to selected item (should be first in the queue response)
|
||||
if (mounted) {
|
||||
await navigateToVideoPlayer(context, metadata: playQueue.items!.first);
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to play from item', error: e);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
t.messages.failedPlayback(
|
||||
action: t.discover.play,
|
||||
error: e.toString(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
CustomAppBar(
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.playlist.title,
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
if (widget.playlist.smart)
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.auto_awesome,
|
||||
size: 12,
|
||||
color: Colors.blue[300],
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
t.playlists.smartPlaylist,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.blue[300],
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
pinned: true,
|
||||
actions: buildAppBarActions(
|
||||
onDelete: widget.playlist.smart ? null : _deletePlaylist,
|
||||
deleteTooltip: t.playlists.delete,
|
||||
showDelete: !widget.playlist.smart,
|
||||
),
|
||||
),
|
||||
...buildStateSlivers(),
|
||||
if (items.isNotEmpty)
|
||||
if (widget.playlist.smart)
|
||||
// Smart playlists: Use grid view (cannot be reordered)
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
|
||||
sliver: SliverGrid(
|
||||
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent:
|
||||
GridSizeCalculator.getMaxCrossAxisExtent(
|
||||
context,
|
||||
context.watch<SettingsProvider>().libraryDensity,
|
||||
),
|
||||
childAspectRatio: 2 / 3.3,
|
||||
crossAxisSpacing: 0,
|
||||
mainAxisSpacing: 0,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
return MediaCard(item: items[index], onRefresh: updateItem);
|
||||
}, childCount: items.length),
|
||||
),
|
||||
)
|
||||
else
|
||||
// Regular playlists: Use reorderable list view
|
||||
SliverReorderableList(
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
return PlaylistItemCard(
|
||||
key: ValueKey(item.playlistItemID ?? item.ratingKey),
|
||||
item: item,
|
||||
index: index,
|
||||
onRemove: () => _removeItem(index),
|
||||
onTap: () => _playFromItem(index),
|
||||
canReorder: !widget.playlist.smart,
|
||||
);
|
||||
},
|
||||
itemCount: items.length,
|
||||
onReorder: _onReorder,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import '../providers/user_profile_provider.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
import '../widgets/profile_list_tile.dart';
|
||||
import '../widgets/desktop_app_bar.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
|
||||
class ProfileSwitchScreen extends StatelessWidget {
|
||||
const ProfileSwitchScreen({super.key});
|
||||
@@ -16,7 +17,7 @@ class ProfileSwitchScreen extends StatelessWidget {
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
const CustomAppBar(title: Text('Switch Profile')),
|
||||
CustomAppBar(title: Text(t.screens.switchProfile)),
|
||||
SliverFillRemaining(
|
||||
child: Consumer<UserProfileProvider>(
|
||||
builder: (context, userProvider, child) {
|
||||
@@ -41,7 +42,7 @@ class ProfileSwitchScreen extends StatelessWidget {
|
||||
onPressed: () {
|
||||
userProvider.refreshCurrentUser();
|
||||
},
|
||||
child: const Text('Retry'),
|
||||
child: Text(t.common.retry),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -115,7 +116,9 @@ class ProfileSwitchScreen extends StatelessWidget {
|
||||
} else if (!success && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to switch to ${user.displayName}'),
|
||||
content: Text(
|
||||
t.errors.failedToSwitchProfile(displayName: user.displayName),
|
||||
),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import '../client/plex_client.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
import '../widgets/media_card.dart';
|
||||
import '../widgets/desktop_app_bar.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../mixins/refreshable.dart';
|
||||
import '../mixins/item_updatable.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../providers/multi_server_provider.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/grid_cross_axis_extent.dart';
|
||||
import '../widgets/desktop_app_bar.dart';
|
||||
import '../widgets/media_card.dart';
|
||||
|
||||
class SearchScreen extends StatefulWidget {
|
||||
const SearchScreen({super.key});
|
||||
@@ -15,11 +21,7 @@ class SearchScreen extends StatefulWidget {
|
||||
State<SearchScreen> createState() => _SearchScreenState();
|
||||
}
|
||||
|
||||
class _SearchScreenState extends State<SearchScreen>
|
||||
with Refreshable, ItemUpdatable {
|
||||
@override
|
||||
PlexClient get client => context.clientSafe;
|
||||
|
||||
class _SearchScreenState extends State<SearchScreen> with Refreshable {
|
||||
final _searchController = TextEditingController();
|
||||
List<PlexMetadata> _searchResults = [];
|
||||
bool _isSearching = false;
|
||||
@@ -83,13 +85,18 @@ class _SearchScreenState extends State<SearchScreen>
|
||||
});
|
||||
|
||||
try {
|
||||
final clientProvider = context.plexClient;
|
||||
final client = clientProvider.client;
|
||||
if (client == null) {
|
||||
throw Exception('No client available');
|
||||
final multiServerProvider = Provider.of<MultiServerProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
|
||||
if (!multiServerProvider.hasConnectedServers) {
|
||||
throw Exception('No servers available');
|
||||
}
|
||||
|
||||
final results = await client.search(query);
|
||||
// Search across all connected servers
|
||||
final results = await multiServerProvider.aggregationService
|
||||
.searchAcrossServers(query);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_searchResults = results;
|
||||
@@ -102,9 +109,9 @@ class _SearchScreenState extends State<SearchScreen>
|
||||
setState(() {
|
||||
_isSearching = false;
|
||||
});
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Search failed: $e')));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(t.errors.searchFailed(error: e))),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,13 +124,25 @@ class _SearchScreenState extends State<SearchScreen>
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) {
|
||||
final index = _searchResults.indexWhere(
|
||||
(item) => item.ratingKey == ratingKey,
|
||||
// Public method to fully reload all content (for profile switches)
|
||||
void fullRefresh() {
|
||||
appLogger.d(
|
||||
'SearchScreen.fullRefresh() called - clearing search and reloading',
|
||||
);
|
||||
if (index != -1) {
|
||||
_searchResults[index] = updatedMetadata;
|
||||
// Clear search results and search text for new profile
|
||||
_searchController.clear();
|
||||
setState(() {
|
||||
_searchResults.clear();
|
||||
_isSearching = false;
|
||||
_hasSearched = false;
|
||||
_lastSearchedQuery = '';
|
||||
});
|
||||
}
|
||||
|
||||
void updateItem(String ratingKey) {
|
||||
// Trigger a refresh of the search to get updated metadata
|
||||
if (_searchController.text.isNotEmpty) {
|
||||
_performSearch(_searchController.text);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,13 +152,13 @@ class _SearchScreenState extends State<SearchScreen>
|
||||
body: SafeArea(
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
DesktopSliverAppBar(title: const Text('Search'), floating: true),
|
||||
DesktopSliverAppBar(title: Text(t.screens.search), floating: true),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: SearchBar(
|
||||
controller: _searchController,
|
||||
hintText: 'Search movies, shows, music...',
|
||||
hintText: t.search.hint,
|
||||
leading: const Icon(Icons.search),
|
||||
trailing: [
|
||||
if (_searchController.text.isNotEmpty)
|
||||
@@ -168,14 +187,14 @@ class _SearchScreenState extends State<SearchScreen>
|
||||
Icon(Icons.search, size: 80, color: Colors.grey.shade400),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Search your media',
|
||||
t.search.searchYourMedia,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Enter a title, actor, or keyword',
|
||||
t.search.enterTitleActorOrKeyword,
|
||||
style: TextStyle(color: Colors.grey.shade600),
|
||||
),
|
||||
],
|
||||
@@ -195,14 +214,14 @@ class _SearchScreenState extends State<SearchScreen>
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No results found',
|
||||
t.messages.noResultsFound,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Try a different search term',
|
||||
t.search.tryDifferentTerm,
|
||||
style: TextStyle(color: Colors.grey.shade600),
|
||||
),
|
||||
],
|
||||
@@ -210,24 +229,48 @@ class _SearchScreenState extends State<SearchScreen>
|
||||
),
|
||||
)
|
||||
else
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
sliver: SliverGrid(
|
||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 180,
|
||||
childAspectRatio: 2 / 3.3,
|
||||
crossAxisSpacing: 8,
|
||||
mainAxisSpacing: 8,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final item = _searchResults[index];
|
||||
return MediaCard(
|
||||
key: Key(item.ratingKey),
|
||||
item: item,
|
||||
onRefresh: updateItem,
|
||||
Consumer<SettingsProvider>(
|
||||
builder: (context, settingsProvider, child) {
|
||||
if (settingsProvider.viewMode == ViewMode.list) {
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final item = _searchResults[index];
|
||||
return MediaCard(
|
||||
key: Key(item.ratingKey),
|
||||
item: item,
|
||||
onRefresh: updateItem,
|
||||
);
|
||||
}, childCount: _searchResults.length),
|
||||
),
|
||||
);
|
||||
}, childCount: _searchResults.length),
|
||||
),
|
||||
} else {
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
sliver: SliverGrid(
|
||||
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: getMaxCrossAxisExtentWithPadding(
|
||||
context,
|
||||
settingsProvider.libraryDensity,
|
||||
32,
|
||||
),
|
||||
childAspectRatio: 2 / 3.3,
|
||||
crossAxisSpacing: 8,
|
||||
mainAxisSpacing: 8,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final item = _searchResults[index];
|
||||
return MediaCard(
|
||||
key: Key(item.ratingKey),
|
||||
item: item,
|
||||
onRefresh: updateItem,
|
||||
);
|
||||
}, childCount: _searchResults.length),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../client/plex_client.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../providers/plex_client_provider.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
import '../utils/video_player_navigation.dart';
|
||||
import '../utils/duration_formatter.dart';
|
||||
import '../widgets/desktop_app_bar.dart';
|
||||
import '../widgets/media_context_menu.dart';
|
||||
import '../mixins/item_updatable.dart';
|
||||
import '../theme/theme_helper.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
|
||||
class SeasonDetailScreen extends StatefulWidget {
|
||||
final PlexMetadata season;
|
||||
@@ -22,17 +22,28 @@ class SeasonDetailScreen extends StatefulWidget {
|
||||
|
||||
class _SeasonDetailScreenState extends State<SeasonDetailScreen>
|
||||
with ItemUpdatable {
|
||||
late final PlexClient _client;
|
||||
|
||||
@override
|
||||
PlexClient get client => context.clientSafe;
|
||||
PlexClient get client => _client;
|
||||
|
||||
List<PlexMetadata> _episodes = [];
|
||||
bool _isLoadingEpisodes = false;
|
||||
bool _watchStateChanged = false;
|
||||
|
||||
/// Get the correct PlexClient for this season's server
|
||||
PlexClient _getClientForSeason(BuildContext context) {
|
||||
return context.getClientForServer(widget.season.serverId!);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadEpisodes();
|
||||
// Initialize the client once in initState
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_client = _getClientForSeason(context);
|
||||
_loadEpisodes();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadEpisodes() async {
|
||||
@@ -41,13 +52,9 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen>
|
||||
});
|
||||
|
||||
try {
|
||||
final clientProvider = context.plexClient;
|
||||
final client = clientProvider.client;
|
||||
if (client == null) {
|
||||
throw Exception('No client available');
|
||||
}
|
||||
// Episodes are automatically tagged with server info by PlexClient
|
||||
final episodes = await _client.getChildren(widget.season.ratingKey);
|
||||
|
||||
final episodes = await client.getChildren(widget.season.ratingKey);
|
||||
setState(() {
|
||||
_episodes = episodes;
|
||||
_isLoadingEpisodes = false;
|
||||
@@ -100,7 +107,7 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen>
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No episodes found',
|
||||
t.messages.noEpisodesFoundGeneral,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
color: tokens(context).textMuted,
|
||||
),
|
||||
@@ -131,7 +138,7 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen>
|
||||
: 0.0;
|
||||
|
||||
return MediaContextMenu(
|
||||
metadata: episode,
|
||||
item: episode,
|
||||
onRefresh: updateItem,
|
||||
onTap: () async {
|
||||
await navigateToVideoPlayer(context, metadata: episode);
|
||||
@@ -163,21 +170,10 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen>
|
||||
child: AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: episode.thumb != null
|
||||
? Consumer<PlexClientProvider>(
|
||||
builder: (context, clientProvider, child) {
|
||||
final client = clientProvider.client;
|
||||
if (client == null) {
|
||||
return Container(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
child: const Center(
|
||||
child: Icon(Icons.movie, size: 40),
|
||||
),
|
||||
);
|
||||
}
|
||||
? Builder(
|
||||
builder: (context) {
|
||||
return CachedNetworkImage(
|
||||
imageUrl: client.getThumbnailUrl(
|
||||
imageUrl: _client.getThumbnailUrl(
|
||||
episode.thumb,
|
||||
),
|
||||
filterQuality: FilterQuality.medium,
|
||||
@@ -329,7 +325,9 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen>
|
||||
children: [
|
||||
if (episode.duration != null)
|
||||
Text(
|
||||
_formatDuration(episode.duration!),
|
||||
formatDurationTimestamp(
|
||||
Duration(milliseconds: episode.duration!),
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: tokens(context).textMuted,
|
||||
@@ -349,7 +347,7 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen>
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Watched ✓',
|
||||
'${t.discover.watched} ✓',
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: tokens(context).textMuted,
|
||||
@@ -368,17 +366,4 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen>
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDuration(int milliseconds) {
|
||||
final duration = Duration(milliseconds: milliseconds);
|
||||
final hours = duration.inHours;
|
||||
final minutes = duration.inMinutes.remainder(60);
|
||||
final seconds = duration.inSeconds.remainder(60);
|
||||
|
||||
if (hours > 0) {
|
||||
return '$hours:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
|
||||
} else {
|
||||
return '$minutes:${seconds.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/plex_auth_service.dart';
|
||||
import '../services/storage_service.dart';
|
||||
import '../services/server_connection_service.dart';
|
||||
import '../widgets/server_list_tile.dart';
|
||||
import '../widgets/desktop_app_bar.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
import 'main_screen.dart';
|
||||
|
||||
class ServerSelectionScreen extends StatefulWidget {
|
||||
final PlexAuthService authService;
|
||||
final String plexToken;
|
||||
|
||||
const ServerSelectionScreen({
|
||||
super.key,
|
||||
required this.authService,
|
||||
required this.plexToken,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ServerSelectionScreen> createState() => _ServerSelectionScreenState();
|
||||
}
|
||||
|
||||
class _ServerSelectionScreenState extends State<ServerSelectionScreen> {
|
||||
List<PlexServer>? _servers;
|
||||
bool _isLoading = true;
|
||||
String? _errorMessage;
|
||||
String? _currentServerUrl;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadCurrentServerUrl();
|
||||
_loadServers();
|
||||
}
|
||||
|
||||
Future<void> _loadCurrentServerUrl() async {
|
||||
try {
|
||||
final storage = await StorageService.getInstance();
|
||||
setState(() {
|
||||
_currentServerUrl = storage.getServerUrl();
|
||||
});
|
||||
} catch (e) {
|
||||
appLogger.w('Failed to load current server URL', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadServers() async {
|
||||
try {
|
||||
final servers = await widget.authService.fetchServers(widget.plexToken);
|
||||
setState(() {
|
||||
_servers = servers;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_errorMessage = 'Failed to load servers: $e';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _selectServer(PlexServer server) async {
|
||||
// Show loading dialog
|
||||
if (mounted) {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => const AlertDialog(
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('Connecting to server...'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Get client identifier and storage
|
||||
final storage = await StorageService.getInstance();
|
||||
final clientId =
|
||||
storage.getClientIdentifier() ?? widget.authService.clientIdentifier;
|
||||
|
||||
// Get current profile context to maintain profile across server switch
|
||||
final currentUserUUID = storage.getCurrentUserUUID();
|
||||
|
||||
PlexServer serverWithCorrectToken = server;
|
||||
|
||||
// If we have a current profile, get a profile-specific token for this server
|
||||
if (currentUserUUID != null) {
|
||||
try {
|
||||
// Switch to the current profile on the new server to get the correct token
|
||||
final switchResponse = await widget.authService.switchToUser(
|
||||
currentUserUUID,
|
||||
widget.plexToken,
|
||||
);
|
||||
|
||||
// Get servers with the profile's Plex.tv token to get profile-specific server tokens
|
||||
final servers = await widget.authService.fetchServers(
|
||||
switchResponse.authToken,
|
||||
);
|
||||
|
||||
// Find the matching server with the profile-specific token
|
||||
final matchingServer = servers.firstWhere(
|
||||
(s) =>
|
||||
s.name == server.name ||
|
||||
s.clientIdentifier == server.clientIdentifier,
|
||||
orElse: () => server, // Fallback to original server
|
||||
);
|
||||
|
||||
serverWithCorrectToken = matchingServer;
|
||||
appLogger.d(
|
||||
'Got profile-specific token for server ${server.name} and user UUID $currentUserUUID',
|
||||
);
|
||||
} catch (e) {
|
||||
appLogger.w(
|
||||
'Failed to get profile-specific token, using default server token',
|
||||
error: e,
|
||||
);
|
||||
// Continue with original server token as fallback
|
||||
}
|
||||
}
|
||||
|
||||
// Connect using the server with the correct token
|
||||
final result = await ServerConnectionService.connectToServer(
|
||||
serverWithCorrectToken,
|
||||
clientIdentifier: clientId,
|
||||
plexToken: widget.plexToken,
|
||||
);
|
||||
|
||||
// Close loading dialog
|
||||
if (mounted) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
// Handle result
|
||||
if (result.isSuccess) {
|
||||
// Set client in provider before navigation (same pattern as auto-login)
|
||||
if (mounted) {
|
||||
context.plexClient.setClient(result.client!);
|
||||
|
||||
// Navigate to main app and clear navigation stack
|
||||
Navigator.pushAndRemoveUntil(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => MainScreen(client: result.client!),
|
||||
),
|
||||
(route) => false, // Remove all routes
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Show error
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(result.error ?? 'Connection failed')),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Close loading dialog on error
|
||||
if (mounted) {
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to connect to server: $e')),
|
||||
);
|
||||
}
|
||||
appLogger.e('Server selection failed', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
bool _isCurrentServer(PlexServer server) {
|
||||
if (_currentServerUrl == null) return false;
|
||||
|
||||
// Check all server connections to see if any match the current server URL
|
||||
for (final connection in server.connections) {
|
||||
if (connection.uri == _currentServerUrl) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
const CustomAppBar(title: Text('Select Server')),
|
||||
SliverFillRemaining(
|
||||
child: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _errorMessage != null
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
_errorMessage!,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _loadServers,
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: _servers == null || _servers!.isEmpty
|
||||
? const Center(child: Text('No servers found'))
|
||||
: ListView.builder(
|
||||
itemCount: _servers!.length,
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemBuilder: (context, index) {
|
||||
final server = _servers![index];
|
||||
final isCurrentServer = _isCurrentServer(server);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Card(
|
||||
child: ServerListTile(
|
||||
server: server,
|
||||
isCurrentServer: isCurrentServer,
|
||||
onTap: () => _selectServer(server),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flex_color_picker/flex_color_picker.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../widgets/desktop_app_bar.dart';
|
||||
|
||||
class SubtitleStylingScreen extends StatefulWidget {
|
||||
const SubtitleStylingScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SubtitleStylingScreen> createState() => _SubtitleStylingScreenState();
|
||||
}
|
||||
|
||||
// Composable widget for slider sections
|
||||
class _StylingSliderSection extends StatelessWidget {
|
||||
final String label;
|
||||
final int value;
|
||||
final double min;
|
||||
final double max;
|
||||
final int divisions;
|
||||
final ValueChanged<double> onChanged;
|
||||
final ValueChanged<double>? onChangeEnd;
|
||||
final String Function(int)? valueFormatter;
|
||||
|
||||
const _StylingSliderSection({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.min,
|
||||
required this.max,
|
||||
required this.divisions,
|
||||
required this.onChanged,
|
||||
this.onChangeEnd,
|
||||
this.valueFormatter,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final formattedValue = valueFormatter?.call(value) ?? value.toString();
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [Text(label), Text(formattedValue)],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
valueFormatter?.call(min.toInt()) ?? min.toInt().toString(),
|
||||
style: const TextStyle(fontSize: 12, color: Colors.grey),
|
||||
),
|
||||
Expanded(
|
||||
child: Slider(
|
||||
value: value.toDouble(),
|
||||
min: min,
|
||||
max: max,
|
||||
divisions: divisions,
|
||||
label: formattedValue,
|
||||
onChanged: onChanged,
|
||||
onChangeEnd: onChangeEnd,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
valueFormatter?.call(max.toInt()) ?? max.toInt().toString(),
|
||||
style: const TextStyle(fontSize: 12, color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Composable widget for color picker tiles
|
||||
class _ColorSettingTile extends StatelessWidget {
|
||||
final String label;
|
||||
final String currentColor;
|
||||
final VoidCallback onTap;
|
||||
final Color Function(String) hexToColor;
|
||||
|
||||
const _ColorSettingTile({
|
||||
required this.label,
|
||||
required this.currentColor,
|
||||
required this.onTap,
|
||||
required this.hexToColor,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
leading: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: hexToColor(currentColor),
|
||||
border: Border.all(color: Colors.grey),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
title: Text(label),
|
||||
subtitle: Text(currentColor),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SubtitleStylingScreenState extends State<SubtitleStylingScreen> {
|
||||
late SettingsService _settingsService;
|
||||
bool _isLoading = true;
|
||||
|
||||
int _fontSize = 55;
|
||||
String _textColor = '#FFFFFF';
|
||||
int _borderSize = 3;
|
||||
String _borderColor = '#000000';
|
||||
String _backgroundColor = '#000000';
|
||||
int _backgroundOpacity = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadSettings();
|
||||
}
|
||||
|
||||
Future<void> _loadSettings() async {
|
||||
_settingsService = await SettingsService.getInstance();
|
||||
|
||||
setState(() {
|
||||
_fontSize = _settingsService.getSubtitleFontSize();
|
||||
_textColor = _settingsService.getSubtitleTextColor();
|
||||
_borderSize = _settingsService.getSubtitleBorderSize();
|
||||
_borderColor = _settingsService.getSubtitleBorderColor();
|
||||
_backgroundColor = _settingsService.getSubtitleBackgroundColor();
|
||||
_backgroundOpacity = _settingsService.getSubtitleBackgroundOpacity();
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
Color _hexToColor(String hexString) {
|
||||
final buffer = StringBuffer();
|
||||
if (hexString.length == 7) buffer.write('ff');
|
||||
buffer.write(hexString.replaceFirst('#', ''));
|
||||
return Color(int.parse(buffer.toString(), radix: 16));
|
||||
}
|
||||
|
||||
String _colorToHex(Color color) {
|
||||
return '#${((color.r * 255.0).round() & 0xff).toRadixString(16).padLeft(2, '0')}${((color.g * 255.0).round() & 0xff).toRadixString(16).padLeft(2, '0')}${((color.b * 255.0).round() & 0xff).toRadixString(16).padLeft(2, '0')}'
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
Future<void> _showColorPicker(
|
||||
String title,
|
||||
String currentColor,
|
||||
Function(String) onColorSelected,
|
||||
) async {
|
||||
Color initialColor = _hexToColor(currentColor);
|
||||
|
||||
final Color selectedColor = await showColorPickerDialog(
|
||||
context,
|
||||
initialColor,
|
||||
title: Text(title),
|
||||
barrierColor: Colors.black54,
|
||||
width: 40,
|
||||
height: 40,
|
||||
spacing: 0,
|
||||
runSpacing: 0,
|
||||
borderRadius: 4,
|
||||
wheelDiameter: 165,
|
||||
enableOpacity: false,
|
||||
showColorCode: true,
|
||||
colorCodeHasColor: true,
|
||||
pickersEnabled: const <ColorPickerType, bool>{
|
||||
ColorPickerType.both: false,
|
||||
ColorPickerType.primary: true,
|
||||
ColorPickerType.accent: false,
|
||||
ColorPickerType.wheel: true,
|
||||
ColorPickerType.custom: false,
|
||||
},
|
||||
actionButtons: const ColorPickerActionButtons(
|
||||
okButton: true,
|
||||
closeButton: true,
|
||||
dialogActionButtons: false,
|
||||
),
|
||||
);
|
||||
|
||||
final hexColor = _colorToHex(selectedColor);
|
||||
onColorSelected(hexColor);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isLoading) {
|
||||
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
CustomAppBar(title: Text(t.screens.subtitleStyling), pinned: true),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildListDelegate([
|
||||
_buildStylingCard(),
|
||||
const SizedBox(height: 24),
|
||||
]),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStylingCard() {
|
||||
return Card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
t.subtitlingStyling.stylingOptions,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
// Font Size Slider
|
||||
_StylingSliderSection(
|
||||
label: t.subtitlingStyling.fontSize,
|
||||
value: _fontSize,
|
||||
min: 30,
|
||||
max: 80,
|
||||
divisions: 50,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_fontSize = value.toInt();
|
||||
});
|
||||
},
|
||||
onChangeEnd: (value) {
|
||||
_settingsService.setSubtitleFontSize(_fontSize);
|
||||
},
|
||||
),
|
||||
const Divider(),
|
||||
// Text Color
|
||||
_ColorSettingTile(
|
||||
label: t.subtitlingStyling.textColor,
|
||||
currentColor: _textColor,
|
||||
hexToColor: _hexToColor,
|
||||
onTap: () {
|
||||
_showColorPicker(t.subtitlingStyling.textColor, _textColor, (
|
||||
color,
|
||||
) {
|
||||
setState(() {
|
||||
_textColor = color;
|
||||
});
|
||||
_settingsService.setSubtitleTextColor(color);
|
||||
});
|
||||
},
|
||||
),
|
||||
const Divider(),
|
||||
// Border Size Slider
|
||||
_StylingSliderSection(
|
||||
label: t.subtitlingStyling.borderSize,
|
||||
value: _borderSize,
|
||||
min: 0,
|
||||
max: 5,
|
||||
divisions: 5,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_borderSize = value.toInt();
|
||||
});
|
||||
},
|
||||
onChangeEnd: (value) {
|
||||
_settingsService.setSubtitleBorderSize(_borderSize);
|
||||
},
|
||||
),
|
||||
const Divider(),
|
||||
// Border Color
|
||||
_ColorSettingTile(
|
||||
label: t.subtitlingStyling.borderColor,
|
||||
currentColor: _borderColor,
|
||||
hexToColor: _hexToColor,
|
||||
onTap: () {
|
||||
_showColorPicker(t.subtitlingStyling.borderColor, _borderColor, (
|
||||
color,
|
||||
) {
|
||||
setState(() {
|
||||
_borderColor = color;
|
||||
});
|
||||
_settingsService.setSubtitleBorderColor(color);
|
||||
});
|
||||
},
|
||||
),
|
||||
const Divider(),
|
||||
// Background Opacity Slider
|
||||
_StylingSliderSection(
|
||||
label: t.subtitlingStyling.backgroundOpacity,
|
||||
value: _backgroundOpacity,
|
||||
min: 0,
|
||||
max: 100,
|
||||
divisions: 20,
|
||||
valueFormatter: (value) => '$value%',
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_backgroundOpacity = value.toInt();
|
||||
});
|
||||
},
|
||||
onChangeEnd: (value) {
|
||||
_settingsService.setSubtitleBackgroundOpacity(_backgroundOpacity);
|
||||
},
|
||||
),
|
||||
const Divider(),
|
||||
// Background Color
|
||||
_ColorSettingTile(
|
||||
label: t.subtitlingStyling.backgroundColor,
|
||||
currentColor: _backgroundColor,
|
||||
hexToColor: _hexToColor,
|
||||
onTap: () {
|
||||
_showColorPicker(
|
||||
t.subtitlingStyling.backgroundColor,
|
||||
_backgroundColor,
|
||||
(color) {
|
||||
setState(() {
|
||||
_backgroundColor = color;
|
||||
});
|
||||
_settingsService.setSubtitleBackgroundColor(color);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import 'dart:async';
|
||||
|
||||
import '../client/plex_client.dart';
|
||||
import '../models/plex_hub.dart';
|
||||
import '../models/plex_library.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import 'multi_server_manager.dart';
|
||||
import 'plex_auth_service.dart';
|
||||
|
||||
/// Service for aggregating data from multiple Plex servers
|
||||
class DataAggregationService {
|
||||
final MultiServerManager _serverManager;
|
||||
|
||||
DataAggregationService(this._serverManager);
|
||||
|
||||
/// Fetch libraries from all online servers
|
||||
/// Libraries are automatically tagged with server info by PlexClient
|
||||
Future<List<PlexLibrary>> getLibrariesFromAllServers() async {
|
||||
return _perServer<PlexLibrary>(
|
||||
operationName: 'fetching libraries',
|
||||
operation: (serverId, client, server) async {
|
||||
return await client.getLibraries();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Fetch "On Deck" (Continue Watching) from all servers and merge by recency
|
||||
/// Items are automatically tagged with server info by PlexClient
|
||||
Future<List<PlexMetadata>> getOnDeckFromAllServers({int? limit}) async {
|
||||
final allOnDeck = await _perServer<PlexMetadata>(
|
||||
operationName: 'fetching on deck',
|
||||
operation: (serverId, client, server) async {
|
||||
return await client.getOnDeck();
|
||||
},
|
||||
);
|
||||
|
||||
// Sort by most recently viewed
|
||||
// Use lastViewedAt (when item was last viewed), falling back to updatedAt/addedAt if not available
|
||||
allOnDeck.sort((a, b) {
|
||||
final aTime = a.lastViewedAt ?? a.updatedAt ?? a.addedAt ?? 0;
|
||||
final bTime = b.lastViewedAt ?? b.updatedAt ?? b.addedAt ?? 0;
|
||||
return bTime.compareTo(aTime); // Descending (most recent first)
|
||||
});
|
||||
|
||||
// Apply limit if specified
|
||||
final result = limit != null && limit < allOnDeck.length
|
||||
? allOnDeck.sublist(0, limit)
|
||||
: allOnDeck;
|
||||
|
||||
appLogger.i('Fetched ${result.length} on deck items from all servers');
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Fetch recommendation hubs from all servers
|
||||
Future<List<PlexHub>> getHubsFromAllServers({int? limit}) async {
|
||||
final clients = _serverManager.onlineClients;
|
||||
|
||||
if (clients.isEmpty) {
|
||||
appLogger.w('No online servers available for fetching hubs');
|
||||
return [];
|
||||
}
|
||||
|
||||
appLogger.d('Fetching hubs from ${clients.length} servers');
|
||||
|
||||
final allHubs = <PlexHub>[];
|
||||
|
||||
// Fetch from all servers in parallel
|
||||
final hubFutures = clients.entries.map((entry) async {
|
||||
final serverId = entry.key;
|
||||
final client = entry.value;
|
||||
|
||||
try {
|
||||
// Get libraries for this server
|
||||
final libraries = await client.getLibraries();
|
||||
|
||||
// Filter to only visible movie/show libraries
|
||||
final visibleLibraries = libraries.where((library) {
|
||||
if (library.type != 'movie' && library.type != 'show') {
|
||||
return false;
|
||||
}
|
||||
if (library.hidden != null && library.hidden != 0) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}).toList();
|
||||
|
||||
// Fetch hubs from all libraries in parallel
|
||||
final libraryHubFutures = visibleLibraries.map((library) async {
|
||||
try {
|
||||
// Hubs are now tagged with server info at the source
|
||||
return await client.getLibraryHubs(library.key);
|
||||
} catch (e) {
|
||||
appLogger.w(
|
||||
'Failed to fetch hubs for library ${library.title}: $e',
|
||||
);
|
||||
return <PlexHub>[];
|
||||
}
|
||||
});
|
||||
|
||||
final libraryHubResults = await Future.wait(libraryHubFutures);
|
||||
|
||||
// Flatten all library hubs
|
||||
final serverHubs = <PlexHub>[];
|
||||
for (final hubs in libraryHubResults) {
|
||||
serverHubs.addAll(hubs);
|
||||
}
|
||||
|
||||
return serverHubs;
|
||||
} catch (e, stackTrace) {
|
||||
appLogger.e(
|
||||
'Failed to fetch hubs from server $serverId',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
_serverManager.updateServerStatus(serverId, false);
|
||||
return <PlexHub>[];
|
||||
}
|
||||
});
|
||||
|
||||
final results = await Future.wait(hubFutures);
|
||||
|
||||
// Flatten results
|
||||
for (final hubs in results) {
|
||||
allHubs.addAll(hubs);
|
||||
}
|
||||
|
||||
// Apply limit if specified
|
||||
final result = limit != null && limit < allHubs.length
|
||||
? allHubs.sublist(0, limit)
|
||||
: allHubs;
|
||||
|
||||
appLogger.i('Fetched ${result.length} hubs from all servers');
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Search across all online servers
|
||||
/// Results are automatically tagged with server info by PlexClient
|
||||
Future<List<PlexMetadata>> searchAcrossServers(
|
||||
String query, {
|
||||
int? limit,
|
||||
}) async {
|
||||
if (query.trim().isEmpty) {
|
||||
return [];
|
||||
}
|
||||
|
||||
final allResults = await _perServer<PlexMetadata>(
|
||||
operationName: 'searching for "$query"',
|
||||
operation: (serverId, client, server) async {
|
||||
return await client.search(query);
|
||||
},
|
||||
);
|
||||
|
||||
// Apply limit if specified
|
||||
final result = limit != null && limit < allResults.length
|
||||
? allResults.sublist(0, limit)
|
||||
: allResults;
|
||||
|
||||
appLogger.i('Found ${result.length} search results across all servers');
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Get libraries for a specific server
|
||||
Future<List<PlexLibrary>> getLibrariesForServer(String serverId) async {
|
||||
final client = _serverManager.getClient(serverId);
|
||||
|
||||
if (client == null) {
|
||||
appLogger.w('No client found for server $serverId');
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
// Libraries are automatically tagged with server info by PlexClient
|
||||
return await client.getLibraries();
|
||||
} catch (e, stackTrace) {
|
||||
appLogger.e(
|
||||
'Failed to fetch libraries for server $serverId',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
_serverManager.updateServerStatus(serverId, false);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Group libraries by server
|
||||
Map<String, List<PlexLibrary>> groupLibrariesByServer(
|
||||
List<PlexLibrary> libraries,
|
||||
) {
|
||||
final grouped = <String, List<PlexLibrary>>{};
|
||||
|
||||
for (final library in libraries) {
|
||||
final serverId = library.serverId;
|
||||
if (serverId != null) {
|
||||
grouped.putIfAbsent(serverId, () => []).add(library);
|
||||
}
|
||||
}
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
|
||||
/// Higher-order helper for per-server fan-out operations
|
||||
///
|
||||
/// Iterates over all online clients, executes the operation for each server,
|
||||
/// handles errors, updates server status, and aggregates results.
|
||||
///
|
||||
/// Type parameter `T` is the item type returned by the operation
|
||||
/// [operationName] is used for logging (e.g., "fetching libraries")
|
||||
/// [operation] is the async function to run per server, returning `List<T>`
|
||||
Future<List<T>> _perServer<T>({
|
||||
required String operationName,
|
||||
required Future<List<T>> Function(
|
||||
String serverId,
|
||||
PlexClient client,
|
||||
PlexServer? server,
|
||||
)
|
||||
operation,
|
||||
}) async {
|
||||
final clients = _serverManager.onlineClients;
|
||||
|
||||
if (clients.isEmpty) {
|
||||
appLogger.w('No online servers available for $operationName');
|
||||
return [];
|
||||
}
|
||||
|
||||
appLogger.d('$operationName from ${clients.length} servers');
|
||||
|
||||
final allResults = <T>[];
|
||||
|
||||
// Execute operation on all servers in parallel
|
||||
final Iterable<Future<List<T>>> futures = clients.entries.map((
|
||||
entry,
|
||||
) async {
|
||||
final serverId = entry.key;
|
||||
final client = entry.value;
|
||||
final server = _serverManager.getServer(serverId);
|
||||
|
||||
try {
|
||||
return await operation(serverId, client, server);
|
||||
} catch (e, stackTrace) {
|
||||
appLogger.e(
|
||||
'Failed $operationName from server $serverId',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
_serverManager.updateServerStatus(serverId, false);
|
||||
return <T>[];
|
||||
}
|
||||
});
|
||||
|
||||
final List<List<T>> results = await Future.wait<List<T>>(futures);
|
||||
|
||||
// Flatten results
|
||||
for (final items in results) {
|
||||
allResults.addAll(items);
|
||||
}
|
||||
|
||||
return allResults;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../client/plex_client.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../providers/playback_state_provider.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/video_player_navigation.dart';
|
||||
|
||||
/// Result of loading adjacent episodes
|
||||
class AdjacentEpisodes {
|
||||
final PlexMetadata? next;
|
||||
final PlexMetadata? previous;
|
||||
|
||||
AdjacentEpisodes({this.next, this.previous});
|
||||
|
||||
bool get hasNext => next != null;
|
||||
bool get hasPrevious => previous != null;
|
||||
}
|
||||
|
||||
/// Manages episode navigation for TV show playback.
|
||||
///
|
||||
/// Handles:
|
||||
/// - Loading next/previous episodes from play queues
|
||||
/// - Navigating between episodes while preserving track selections
|
||||
/// - Supporting both sequential and shuffle playback modes
|
||||
///
|
||||
/// All episode navigation uses Plex play queues for consistent behavior.
|
||||
class EpisodeNavigationService {
|
||||
/// Load the next and previous episodes for the current episode
|
||||
///
|
||||
/// Returns null for episodes if:
|
||||
/// - Not applicable (e.g., movie content)
|
||||
/// - Next episode doesn't exist (end of season/series)
|
||||
/// - Previous episode doesn't exist (first episode)
|
||||
Future<AdjacentEpisodes> loadAdjacentEpisodes({
|
||||
required BuildContext context,
|
||||
required PlexClient client,
|
||||
required PlexMetadata metadata,
|
||||
}) async {
|
||||
try {
|
||||
final playbackState = context.read<PlaybackStateProvider>();
|
||||
|
||||
// All episode navigation now uses play queues (sequential, shuffle, playlists)
|
||||
// If no queue is active, navigation is not available
|
||||
if (!playbackState.isQueueActive) {
|
||||
return AdjacentEpisodes();
|
||||
}
|
||||
|
||||
// Use the play queue for next/previous navigation
|
||||
final next = await playbackState.getNextEpisode(
|
||||
metadata.ratingKey,
|
||||
loopQueue: false,
|
||||
);
|
||||
final previous = await playbackState.getPreviousEpisode(
|
||||
metadata.ratingKey,
|
||||
);
|
||||
|
||||
final mode = playbackState.isShuffleActive ? 'Shuffle' : 'Sequential';
|
||||
appLogger.d(
|
||||
'$mode mode - Next: ${next?.title}, Previous: ${previous?.title}',
|
||||
);
|
||||
|
||||
return AdjacentEpisodes(next: next, previous: previous);
|
||||
} catch (e) {
|
||||
// Non-critical: Failed to load next/previous episode metadata
|
||||
appLogger.d('Could not load adjacent episodes', error: e);
|
||||
return AdjacentEpisodes();
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigate to the next or previous episode
|
||||
///
|
||||
/// Preserves the current audio track, subtitle track, and playback rate
|
||||
/// selections when transitioning between episodes.
|
||||
Future<void> navigateToEpisode({
|
||||
required BuildContext context,
|
||||
required PlexMetadata episode,
|
||||
required Player? player,
|
||||
bool usePushReplacement = true,
|
||||
}) async {
|
||||
if (!context.mounted) return;
|
||||
|
||||
// Capture current player state before navigation
|
||||
AudioTrack? currentAudioTrack;
|
||||
SubtitleTrack? currentSubtitleTrack;
|
||||
double? currentPlaybackRate;
|
||||
|
||||
if (player != null) {
|
||||
currentAudioTrack = player.state.track.audio;
|
||||
currentSubtitleTrack = player.state.track.subtitle;
|
||||
currentPlaybackRate = player.state.rate;
|
||||
|
||||
appLogger.d(
|
||||
'Navigating to episode with preserved settings - Audio: ${currentAudioTrack.id}, Subtitle: ${currentSubtitleTrack.id}, Rate: ${currentPlaybackRate}x',
|
||||
);
|
||||
}
|
||||
|
||||
// Navigate to the new episode
|
||||
if (context.mounted) {
|
||||
navigateToVideoPlayer(
|
||||
context,
|
||||
metadata: episode,
|
||||
preferredAudioTrack: currentAudioTrack,
|
||||
preferredSubtitleTrack: currentSubtitleTrack,
|
||||
preferredPlaybackRate: currentPlaybackRate,
|
||||
usePushReplacement: usePushReplacement,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:hotkey_manager/hotkey_manager.dart';
|
||||
import 'settings_service.dart';
|
||||
import '../utils/player_utils.dart';
|
||||
|
||||
class KeyboardShortcutsService {
|
||||
static KeyboardShortcutsService? _instance;
|
||||
@@ -10,6 +11,8 @@ class KeyboardShortcutsService {
|
||||
Map<String, String> _shortcuts =
|
||||
{}; // Legacy string shortcuts for backward compatibility
|
||||
Map<String, HotKey> _hotkeys = {}; // New HotKey objects
|
||||
int _seekTimeSmall = 10; // Default, loaded from settings
|
||||
int _seekTimeLarge = 30; // Default, loaded from settings
|
||||
|
||||
KeyboardShortcutsService._();
|
||||
|
||||
@@ -28,6 +31,8 @@ class KeyboardShortcutsService {
|
||||
_shortcuts = _settingsService
|
||||
.getKeyboardShortcuts(); // Keep for legacy compatibility
|
||||
_hotkeys = await _settingsService.getKeyboardHotkeys(); // Primary method
|
||||
_seekTimeSmall = _settingsService.getSeekTimeSmall();
|
||||
_seekTimeLarge = _settingsService.getSeekTimeLarge();
|
||||
}
|
||||
|
||||
Map<String, String> get shortcuts => Map.from(_shortcuts);
|
||||
@@ -61,6 +66,8 @@ class KeyboardShortcutsService {
|
||||
|
||||
Future<void> refreshFromStorage() async {
|
||||
_hotkeys = await _settingsService.getKeyboardHotkeys();
|
||||
_seekTimeSmall = _settingsService.getSeekTimeSmall();
|
||||
_seekTimeLarge = _settingsService.getSeekTimeLarge();
|
||||
}
|
||||
|
||||
Future<void> resetToDefaults() async {
|
||||
@@ -241,32 +248,32 @@ class KeyboardShortcutsService {
|
||||
case 'volume_up':
|
||||
final newVolume = (player.state.volume + 10).clamp(0.0, 100.0);
|
||||
player.setVolume(newVolume);
|
||||
_settingsService.setVolume(newVolume);
|
||||
break;
|
||||
case 'volume_down':
|
||||
final newVolume = (player.state.volume - 10).clamp(0.0, 100.0);
|
||||
player.setVolume(newVolume);
|
||||
_settingsService.setVolume(newVolume);
|
||||
break;
|
||||
case 'seek_forward':
|
||||
final newPosition = player.state.position + const Duration(seconds: 10);
|
||||
player.seek(newPosition);
|
||||
seekWithClamping(player, Duration(seconds: _seekTimeSmall));
|
||||
break;
|
||||
case 'seek_backward':
|
||||
final newPosition = player.state.position - const Duration(seconds: 10);
|
||||
player.seek(newPosition.isNegative ? Duration.zero : newPosition);
|
||||
seekWithClamping(player, Duration(seconds: -_seekTimeSmall));
|
||||
break;
|
||||
case 'seek_forward_large':
|
||||
final newPosition = player.state.position + const Duration(seconds: 30);
|
||||
player.seek(newPosition);
|
||||
seekWithClamping(player, Duration(seconds: _seekTimeLarge));
|
||||
break;
|
||||
case 'seek_backward_large':
|
||||
final newPosition = player.state.position - const Duration(seconds: 30);
|
||||
player.seek(newPosition.isNegative ? Duration.zero : newPosition);
|
||||
seekWithClamping(player, Duration(seconds: -_seekTimeLarge));
|
||||
break;
|
||||
case 'fullscreen_toggle':
|
||||
onToggleFullscreen?.call();
|
||||
break;
|
||||
case 'mute_toggle':
|
||||
player.setVolume(player.state.volume > 0 ? 0.0 : 100.0);
|
||||
final newVolume = player.state.volume > 0 ? 0.0 : 100.0;
|
||||
player.setVolume(newVolume);
|
||||
_settingsService.setVolume(newVolume);
|
||||
break;
|
||||
case 'subtitle_toggle':
|
||||
onToggleSubtitles?.call();
|
||||
@@ -307,13 +314,13 @@ class KeyboardShortcutsService {
|
||||
case 'volume_down':
|
||||
return 'Volume Down';
|
||||
case 'seek_forward':
|
||||
return 'Seek Forward';
|
||||
return 'Seek Forward (${_seekTimeSmall}s)';
|
||||
case 'seek_backward':
|
||||
return 'Seek Backward';
|
||||
return 'Seek Backward (${_seekTimeSmall}s)';
|
||||
case 'seek_forward_large':
|
||||
return 'Seek Forward (Large)';
|
||||
return 'Seek Forward (${_seekTimeLarge}s)';
|
||||
case 'seek_backward_large':
|
||||
return 'Seek Backward (Large)';
|
||||
return 'Seek Backward (${_seekTimeLarge}s)';
|
||||
case 'fullscreen_toggle':
|
||||
return 'Toggle Fullscreen';
|
||||
case 'mute_toggle':
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:os_media_controls/os_media_controls.dart';
|
||||
|
||||
import '../client/plex_client.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
|
||||
/// Manages OS media controls integration for video playback.
|
||||
///
|
||||
/// Handles:
|
||||
/// - Metadata updates (title, artwork, etc.)
|
||||
/// - Playback state updates (playing/paused, position, speed)
|
||||
/// - Control event streaming (play, pause, next, previous, seek)
|
||||
/// - Position update throttling to prevent excessive API calls
|
||||
class MediaControlsManager {
|
||||
/// Stream of control events from OS media controls
|
||||
Stream<dynamic> get controlEvents => OsMediaControls.controlEvents;
|
||||
|
||||
/// Last time position was updated (for throttling)
|
||||
DateTime? _lastPositionUpdate;
|
||||
|
||||
/// Throttle interval for position updates (default: 1 second)
|
||||
final Duration throttleInterval;
|
||||
|
||||
MediaControlsManager({this.throttleInterval = const Duration(seconds: 1)});
|
||||
|
||||
/// Update media metadata displayed in OS media controls
|
||||
///
|
||||
/// This includes title, artist, artwork, and duration.
|
||||
Future<void> updateMetadata({
|
||||
required PlexMetadata metadata,
|
||||
PlexClient? client,
|
||||
Duration? duration,
|
||||
}) async {
|
||||
try {
|
||||
// Build artwork URL if client is available
|
||||
String? artworkUrl;
|
||||
if (client != null && metadata.thumb != null) {
|
||||
try {
|
||||
artworkUrl = client.getThumbnailUrl(metadata.thumb!);
|
||||
appLogger.d('Artwork URL for media controls: $artworkUrl');
|
||||
} catch (e) {
|
||||
appLogger.w('Failed to build artwork URL', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
// Update OS media controls
|
||||
await OsMediaControls.setMetadata(
|
||||
MediaMetadata(
|
||||
title: metadata.title,
|
||||
artist: _buildArtist(metadata),
|
||||
artworkUrl: artworkUrl,
|
||||
duration: duration,
|
||||
),
|
||||
);
|
||||
|
||||
appLogger.d('Updated media controls metadata: ${metadata.title}');
|
||||
} catch (e) {
|
||||
appLogger.w('Failed to update media controls metadata', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Update playback state in OS media controls
|
||||
///
|
||||
/// Updates the current playing state, position, and playback speed.
|
||||
/// Position updates are throttled to avoid excessive API calls.
|
||||
Future<void> updatePlaybackState({
|
||||
required bool isPlaying,
|
||||
required Duration position,
|
||||
required double speed,
|
||||
bool force = false,
|
||||
}) async {
|
||||
try {
|
||||
// Throttle position updates unless forced
|
||||
if (!force) {
|
||||
final now = DateTime.now();
|
||||
if (_lastPositionUpdate != null) {
|
||||
final timeSinceLastUpdate = now.difference(_lastPositionUpdate!);
|
||||
if (timeSinceLastUpdate < throttleInterval) {
|
||||
return; // Skip this update
|
||||
}
|
||||
}
|
||||
_lastPositionUpdate = now;
|
||||
}
|
||||
|
||||
// Only update if playing (avoid excessive updates when paused)
|
||||
if (isPlaying) {
|
||||
await OsMediaControls.setPlaybackState(
|
||||
MediaPlaybackState(
|
||||
state: PlaybackState.playing,
|
||||
position: position,
|
||||
speed: speed,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await OsMediaControls.setPlaybackState(
|
||||
MediaPlaybackState(
|
||||
state: PlaybackState.paused,
|
||||
position: position,
|
||||
speed: speed,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.w('Failed to update media controls playback state', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable or disable next/previous track controls
|
||||
///
|
||||
/// This should be called based on content type and playback mode.
|
||||
/// For example:
|
||||
/// - Episodes: Enable both if there are adjacent episodes
|
||||
/// - Playlist items: Enable based on playlist position
|
||||
/// - Movies: Usually disabled
|
||||
Future<void> setControlsEnabled({
|
||||
bool canGoNext = false,
|
||||
bool canGoPrevious = false,
|
||||
}) async {
|
||||
try {
|
||||
final controls = <MediaControl>[];
|
||||
if (canGoPrevious) controls.add(MediaControl.previous);
|
||||
if (canGoNext) controls.add(MediaControl.next);
|
||||
|
||||
if (controls.isNotEmpty) {
|
||||
await OsMediaControls.enableControls(controls);
|
||||
appLogger.d(
|
||||
'Media controls enabled - Previous: $canGoPrevious, Next: $canGoNext',
|
||||
);
|
||||
} else {
|
||||
await OsMediaControls.disableControls([
|
||||
MediaControl.previous,
|
||||
MediaControl.next,
|
||||
]);
|
||||
appLogger.d('Media controls disabled');
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.w('Failed to set media controls enabled state', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all media controls
|
||||
///
|
||||
/// Should be called when playback stops or screen is disposed.
|
||||
Future<void> clear() async {
|
||||
try {
|
||||
await OsMediaControls.clear();
|
||||
_lastPositionUpdate = null;
|
||||
appLogger.d('Media controls cleared');
|
||||
} catch (e) {
|
||||
appLogger.w('Failed to clear media controls', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispose resources
|
||||
void dispose() {
|
||||
_lastPositionUpdate = null;
|
||||
}
|
||||
|
||||
/// Build artist string from metadata
|
||||
///
|
||||
/// For episodes: "Show Name - Season X Episode Y"
|
||||
/// For movies: Director or studio
|
||||
/// For other content: Fallback to year or empty
|
||||
String _buildArtist(PlexMetadata metadata) {
|
||||
if (metadata.type.toLowerCase() == 'episode') {
|
||||
final parts = <String>[];
|
||||
|
||||
// Add show name
|
||||
if (metadata.grandparentTitle != null) {
|
||||
parts.add(metadata.grandparentTitle!);
|
||||
}
|
||||
|
||||
// Add season/episode info
|
||||
if (metadata.parentIndex != null && metadata.index != null) {
|
||||
parts.add('S${metadata.parentIndex} E${metadata.index}');
|
||||
} else if (metadata.parentTitle != null) {
|
||||
parts.add(metadata.parentTitle!);
|
||||
}
|
||||
|
||||
return parts.join(' • ');
|
||||
} else if (metadata.type.toLowerCase() == 'movie') {
|
||||
// For movies, use director or studio
|
||||
// Note: These fields may need to be added to PlexMetadata model
|
||||
if (metadata.year != null) {
|
||||
return metadata.year.toString();
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
|
||||
import '../client/plex_client.dart';
|
||||
import '../config/plex_config.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import 'plex_auth_service.dart';
|
||||
import 'storage_service.dart';
|
||||
|
||||
/// Manages multiple Plex server connections simultaneously
|
||||
class MultiServerManager {
|
||||
/// Map of serverId (clientIdentifier) to PlexClient instances
|
||||
final Map<String, PlexClient> _clients = {};
|
||||
|
||||
/// Map of serverId to server info
|
||||
final Map<String, PlexServer> _servers = {};
|
||||
|
||||
/// Map of serverId to online status
|
||||
final Map<String, bool> _serverStatus = {};
|
||||
|
||||
/// Stream controller for server status changes
|
||||
final _statusController = StreamController<Map<String, bool>>.broadcast();
|
||||
|
||||
/// Stream of server status changes
|
||||
Stream<Map<String, bool>> get statusStream => _statusController.stream;
|
||||
|
||||
/// Connectivity subscription for network monitoring
|
||||
StreamSubscription<List<ConnectivityResult>>? _connectivitySubscription;
|
||||
|
||||
/// Map of serverId to active optimization futures
|
||||
final Map<String, Future<void>> _activeOptimizations = {};
|
||||
|
||||
/// Get all registered server IDs
|
||||
List<String> get serverIds => _servers.keys.toList();
|
||||
|
||||
/// Get all online server IDs
|
||||
List<String> get onlineServerIds =>
|
||||
_serverStatus.entries.where((e) => e.value).map((e) => e.key).toList();
|
||||
|
||||
/// Get all offline server IDs
|
||||
List<String> get offlineServerIds =>
|
||||
_serverStatus.entries.where((e) => !e.value).map((e) => e.key).toList();
|
||||
|
||||
/// Get client for specific server
|
||||
PlexClient? getClient(String serverId) => _clients[serverId];
|
||||
|
||||
/// Get server info for specific server
|
||||
PlexServer? getServer(String serverId) => _servers[serverId];
|
||||
|
||||
/// Get all online clients
|
||||
Map<String, PlexClient> get onlineClients {
|
||||
final result = <String, PlexClient>{};
|
||||
for (final serverId in onlineServerIds) {
|
||||
final client = _clients[serverId];
|
||||
if (client != null) {
|
||||
result[serverId] = client;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Get all servers
|
||||
Map<String, PlexServer> get servers => Map.unmodifiable(_servers);
|
||||
|
||||
/// Check if a server is online
|
||||
bool isServerOnline(String serverId) => _serverStatus[serverId] ?? false;
|
||||
|
||||
/// Connect to all available servers in parallel
|
||||
/// Returns the number of successfully connected servers
|
||||
Future<int> connectToAllServers(
|
||||
List<PlexServer> servers, {
|
||||
String? clientIdentifier,
|
||||
Duration timeout = const Duration(seconds: 10),
|
||||
Function(String serverId, PlexClient client)? onServerConnected,
|
||||
Function(String serverId, Object error)? onServerFailed,
|
||||
}) async {
|
||||
if (servers.isEmpty) {
|
||||
appLogger.w('No servers to connect to');
|
||||
return 0;
|
||||
}
|
||||
|
||||
appLogger.i('Connecting to ${servers.length} servers...');
|
||||
|
||||
// Use provided client ID or generate a unique one for this app instance
|
||||
final effectiveClientId =
|
||||
clientIdentifier ?? DateTime.now().millisecondsSinceEpoch.toString();
|
||||
|
||||
// Create connection tasks for all servers
|
||||
final connectionFutures = servers.map((server) async {
|
||||
final serverId = server.clientIdentifier;
|
||||
|
||||
try {
|
||||
appLogger.d('Attempting connection to server: ${server.name}');
|
||||
|
||||
// Find best working connection for this server
|
||||
PlexConnection? workingConnection;
|
||||
|
||||
await for (final connection in server.findBestWorkingConnection()) {
|
||||
workingConnection = connection;
|
||||
// Use first working connection (could wait for optimal, but that's slower)
|
||||
break;
|
||||
}
|
||||
|
||||
if (workingConnection == null) {
|
||||
throw Exception('No working connection found');
|
||||
}
|
||||
|
||||
final baseUrl = workingConnection.uri;
|
||||
appLogger.d('Connected to ${server.name} at $baseUrl');
|
||||
|
||||
// Get storage and load cached endpoint for this server
|
||||
final storage = await StorageService.getInstance();
|
||||
final cachedEndpoint = storage.getServerEndpoint(serverId);
|
||||
|
||||
// Create PlexClient with the working connection and failover support
|
||||
final prioritizedEndpoints = server.prioritizedEndpointUrls(
|
||||
preferredFirst: cachedEndpoint ?? baseUrl,
|
||||
);
|
||||
final config = await PlexConfig.create(
|
||||
baseUrl: baseUrl,
|
||||
token: server.accessToken,
|
||||
clientIdentifier: effectiveClientId,
|
||||
);
|
||||
|
||||
final client = PlexClient(
|
||||
config,
|
||||
serverId: serverId,
|
||||
serverName: server.name,
|
||||
prioritizedEndpoints: prioritizedEndpoints,
|
||||
onEndpointChanged: (newUrl) async {
|
||||
await storage.saveServerEndpoint(serverId, newUrl);
|
||||
appLogger.i(
|
||||
'Updated endpoint for ${server.name} after failover: $newUrl',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Save the initial endpoint
|
||||
await storage.saveServerEndpoint(serverId, baseUrl);
|
||||
|
||||
// Store the client and server info
|
||||
_clients[serverId] = client;
|
||||
_servers[serverId] = server;
|
||||
_serverStatus[serverId] = true;
|
||||
|
||||
onServerConnected?.call(serverId, client);
|
||||
appLogger.i('Successfully connected to ${server.name}');
|
||||
|
||||
return serverId;
|
||||
} catch (e, stackTrace) {
|
||||
appLogger.e(
|
||||
'Failed to connect to ${server.name}',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
|
||||
// Mark as offline
|
||||
_servers[serverId] = server;
|
||||
_serverStatus[serverId] = false;
|
||||
|
||||
onServerFailed?.call(serverId, e);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for all connections with timeout
|
||||
final results = await Future.wait(
|
||||
connectionFutures.map(
|
||||
(f) => f.timeout(
|
||||
timeout,
|
||||
onTimeout: () {
|
||||
appLogger.w('Server connection timed out');
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Count successful connections
|
||||
final successCount = results.where((id) => id != null).length;
|
||||
|
||||
// Notify listeners of status change
|
||||
_statusController.add(Map.from(_serverStatus));
|
||||
|
||||
appLogger.i(
|
||||
'Connected to $successCount/${servers.length} servers successfully',
|
||||
);
|
||||
|
||||
// Start network monitoring if we have any connected servers
|
||||
if (successCount > 0) {
|
||||
startNetworkMonitoring();
|
||||
}
|
||||
|
||||
return successCount;
|
||||
}
|
||||
|
||||
/// Add a single server connection
|
||||
Future<bool> addServer(PlexServer server, {String? clientIdentifier}) async {
|
||||
final serverId = server.clientIdentifier;
|
||||
final effectiveClientId =
|
||||
clientIdentifier ?? DateTime.now().millisecondsSinceEpoch.toString();
|
||||
|
||||
try {
|
||||
appLogger.d('Adding server: ${server.name}');
|
||||
|
||||
// Find best working connection
|
||||
PlexConnection? workingConnection;
|
||||
|
||||
await for (final connection in server.findBestWorkingConnection()) {
|
||||
workingConnection = connection;
|
||||
break;
|
||||
}
|
||||
|
||||
if (workingConnection == null) {
|
||||
throw Exception('No working connection found');
|
||||
}
|
||||
|
||||
final baseUrl = workingConnection.uri;
|
||||
|
||||
// Get storage and load cached endpoint for this server
|
||||
final storage = await StorageService.getInstance();
|
||||
final cachedEndpoint = storage.getServerEndpoint(serverId);
|
||||
|
||||
// Create PlexClient with failover support
|
||||
final prioritizedEndpoints = server.prioritizedEndpointUrls(
|
||||
preferredFirst: cachedEndpoint ?? baseUrl,
|
||||
);
|
||||
final config = await PlexConfig.create(
|
||||
baseUrl: baseUrl,
|
||||
token: server.accessToken,
|
||||
clientIdentifier: effectiveClientId,
|
||||
);
|
||||
|
||||
final client = PlexClient(
|
||||
config,
|
||||
serverId: serverId,
|
||||
serverName: server.name,
|
||||
prioritizedEndpoints: prioritizedEndpoints,
|
||||
onEndpointChanged: (newUrl) async {
|
||||
await storage.saveServerEndpoint(serverId, newUrl);
|
||||
appLogger.i(
|
||||
'Updated endpoint for ${server.name} after failover: $newUrl',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Save the initial endpoint
|
||||
await storage.saveServerEndpoint(serverId, baseUrl);
|
||||
|
||||
// Store
|
||||
_clients[serverId] = client;
|
||||
_servers[serverId] = server;
|
||||
_serverStatus[serverId] = true;
|
||||
|
||||
// Notify
|
||||
_statusController.add(Map.from(_serverStatus));
|
||||
|
||||
appLogger.i('Successfully added server: ${server.name}');
|
||||
return true;
|
||||
} catch (e, stackTrace) {
|
||||
appLogger.e(
|
||||
'Failed to add server ${server.name}',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
|
||||
_servers[serverId] = server;
|
||||
_serverStatus[serverId] = false;
|
||||
_statusController.add(Map.from(_serverStatus));
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a server connection
|
||||
void removeServer(String serverId) {
|
||||
_clients.remove(serverId);
|
||||
_servers.remove(serverId);
|
||||
_serverStatus.remove(serverId);
|
||||
_statusController.add(Map.from(_serverStatus));
|
||||
appLogger.i('Removed server: $serverId');
|
||||
}
|
||||
|
||||
/// Update server status (used for health monitoring)
|
||||
void updateServerStatus(String serverId, bool isOnline) {
|
||||
if (_serverStatus[serverId] != isOnline) {
|
||||
_serverStatus[serverId] = isOnline;
|
||||
_statusController.add(Map.from(_serverStatus));
|
||||
appLogger.d('Server $serverId status changed to: $isOnline');
|
||||
}
|
||||
}
|
||||
|
||||
/// Test connection health for all servers
|
||||
Future<void> checkServerHealth() async {
|
||||
appLogger.d('Checking health for ${_clients.length} servers');
|
||||
|
||||
final healthChecks = _clients.entries.map((entry) async {
|
||||
final serverId = entry.key;
|
||||
final client = entry.value;
|
||||
|
||||
try {
|
||||
// Simple ping by fetching server identity
|
||||
await client.getServerIdentity();
|
||||
updateServerStatus(serverId, true);
|
||||
} catch (e) {
|
||||
appLogger.w('Server $serverId health check failed: $e');
|
||||
updateServerStatus(serverId, false);
|
||||
}
|
||||
});
|
||||
|
||||
await Future.wait(healthChecks);
|
||||
}
|
||||
|
||||
/// Start monitoring network connectivity for all servers
|
||||
void startNetworkMonitoring() {
|
||||
if (_connectivitySubscription != null) {
|
||||
appLogger.d('Network monitoring already active');
|
||||
return;
|
||||
}
|
||||
|
||||
appLogger.i('Starting network monitoring for all servers');
|
||||
final connectivity = Connectivity();
|
||||
_connectivitySubscription = connectivity.onConnectivityChanged.listen(
|
||||
(results) {
|
||||
final status = results.isNotEmpty
|
||||
? results.first
|
||||
: ConnectivityResult.none;
|
||||
|
||||
if (status == ConnectivityResult.none) {
|
||||
appLogger.w(
|
||||
'Connectivity lost, pausing optimization until network returns',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
appLogger.d(
|
||||
'Connectivity change detected, re-optimizing all servers',
|
||||
error: {
|
||||
'status': status.name,
|
||||
'interfaces': results.map((r) => r.name).toList(),
|
||||
'serverCount': _servers.length,
|
||||
},
|
||||
);
|
||||
|
||||
// Re-optimize all servers
|
||||
_reoptimizeAllServers(reason: 'connectivity:${status.name}');
|
||||
},
|
||||
onError: (error, stackTrace) {
|
||||
appLogger.w(
|
||||
'Connectivity listener error',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Stop monitoring network connectivity
|
||||
void stopNetworkMonitoring() {
|
||||
_connectivitySubscription?.cancel();
|
||||
_connectivitySubscription = null;
|
||||
appLogger.i('Stopped network monitoring');
|
||||
}
|
||||
|
||||
/// Re-optimize all connected servers
|
||||
void _reoptimizeAllServers({required String reason}) {
|
||||
for (final entry in _servers.entries) {
|
||||
final serverId = entry.key;
|
||||
final server = entry.value;
|
||||
|
||||
// Skip if server is offline
|
||||
if (!isServerOnline(serverId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if optimization already running for this server
|
||||
if (_activeOptimizations.containsKey(serverId)) {
|
||||
appLogger.d(
|
||||
'Optimization already running for ${server.name}, skipping',
|
||||
error: {'reason': reason},
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Run optimization
|
||||
_activeOptimizations[serverId] =
|
||||
_reoptimizeServer(
|
||||
serverId: serverId,
|
||||
server: server,
|
||||
reason: reason,
|
||||
).whenComplete(() {
|
||||
_activeOptimizations.remove(serverId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-optimize connection for a specific server
|
||||
Future<void> _reoptimizeServer({
|
||||
required String serverId,
|
||||
required PlexServer server,
|
||||
required String reason,
|
||||
}) async {
|
||||
final storage = await StorageService.getInstance();
|
||||
final client = _clients[serverId];
|
||||
|
||||
try {
|
||||
appLogger.d(
|
||||
'Starting connection optimization for ${server.name}',
|
||||
error: {'reason': reason},
|
||||
);
|
||||
|
||||
await for (final connection in server.findBestWorkingConnection()) {
|
||||
final newUrl = connection.uri;
|
||||
|
||||
// Check if this is actually a better connection than current
|
||||
if (client != null && client.config.baseUrl == newUrl) {
|
||||
appLogger.d(
|
||||
'Already using optimal endpoint for ${server.name}: $newUrl',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Save the new endpoint
|
||||
await storage.saveServerEndpoint(serverId, newUrl);
|
||||
|
||||
// If client has endpoint failover, it will automatically switch
|
||||
// Otherwise, we might need to recreate the client (but failover should handle it)
|
||||
appLogger.i(
|
||||
'Updated optimal endpoint for ${server.name}: $newUrl',
|
||||
error: {'type': connection.displayType},
|
||||
);
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
appLogger.w(
|
||||
'Connection optimization failed for ${server.name}',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Disconnect all servers
|
||||
void disconnectAll() {
|
||||
appLogger.i('Disconnecting all servers');
|
||||
stopNetworkMonitoring();
|
||||
_clients.clear();
|
||||
_servers.clear();
|
||||
_serverStatus.clear();
|
||||
_activeOptimizations.clear();
|
||||
_statusController.add({});
|
||||
}
|
||||
|
||||
/// Dispose resources
|
||||
void dispose() {
|
||||
disconnectAll();
|
||||
_statusController.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
|
||||
import '../client/plex_client.dart';
|
||||
import '../models/plex_media_info.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../models/plex_user_profile.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import 'track_selection_service.dart';
|
||||
|
||||
/// Service responsible for initializing video playback
|
||||
///
|
||||
/// Handles the complex process of:
|
||||
/// 1. Fetching video playback data from the Plex server
|
||||
/// 2. Building external subtitle tracks
|
||||
/// 3. Opening media in the player
|
||||
/// 4. Adding external subtitles to the player
|
||||
/// 5. Selecting and applying audio/subtitle tracks
|
||||
/// 6. Seeking to resume position
|
||||
/// 7. Starting playback
|
||||
class PlaybackInitializationService {
|
||||
final Player player;
|
||||
final PlexClient client;
|
||||
final BuildContext context;
|
||||
|
||||
PlaybackInitializationService({
|
||||
required this.player,
|
||||
required this.client,
|
||||
required this.context,
|
||||
});
|
||||
|
||||
/// Start playback for the given metadata
|
||||
///
|
||||
/// Returns a PlaybackInitializationResult with available versions and other data
|
||||
Future<PlaybackInitializationResult> startPlayback({
|
||||
required PlexMetadata metadata,
|
||||
required int selectedMediaIndex,
|
||||
required PlexUserProfile? profileSettings,
|
||||
AudioTrack? preferredAudioTrack,
|
||||
SubtitleTrack? preferredSubtitleTrack,
|
||||
double? preferredPlaybackRate,
|
||||
}) async {
|
||||
try {
|
||||
// Get consolidated playback data (URL, media info, and versions) in a single API call
|
||||
final playbackData = await client.getVideoPlaybackData(
|
||||
metadata.ratingKey,
|
||||
mediaIndex: selectedMediaIndex,
|
||||
);
|
||||
|
||||
if (!playbackData.hasValidVideoUrl) {
|
||||
throw PlaybackException(t.messages.fileInfoNotAvailable);
|
||||
}
|
||||
|
||||
final videoUrl = playbackData.videoUrl!;
|
||||
final mediaInfo = playbackData.mediaInfo;
|
||||
|
||||
// Build list of external subtitle tracks for media_kit
|
||||
final externalSubtitles = _buildExternalSubtitles(mediaInfo);
|
||||
|
||||
// Open video (without external subtitles in Media constructor)
|
||||
await player.open(Media(videoUrl), play: false);
|
||||
|
||||
// Wait for media to be ready (duration > 0)
|
||||
await _waitForMediaReady();
|
||||
|
||||
// Add external subtitle tracks without auto-selecting them
|
||||
if (externalSubtitles.isNotEmpty) {
|
||||
await _addExternalSubtitles(externalSubtitles);
|
||||
}
|
||||
|
||||
// Select and apply tracks BEFORE seeking to resume position
|
||||
// This prevents track changes from resetting the playback position on Android
|
||||
final trackSelectionService = TrackSelectionService(
|
||||
player: player,
|
||||
profileSettings: profileSettings,
|
||||
metadata: metadata,
|
||||
);
|
||||
|
||||
await trackSelectionService.selectAndApplyTracks(
|
||||
preferredAudioTrack: preferredAudioTrack,
|
||||
preferredSubtitleTrack: preferredSubtitleTrack,
|
||||
preferredPlaybackRate: preferredPlaybackRate,
|
||||
);
|
||||
|
||||
// Set up playback position if resuming (AFTER track selection)
|
||||
if (metadata.viewOffset != null && metadata.viewOffset! > 0) {
|
||||
appLogger.d('Resuming playback at ${metadata.viewOffset} ms');
|
||||
final resumePosition = Duration(milliseconds: metadata.viewOffset!);
|
||||
await player.seek(resumePosition);
|
||||
}
|
||||
|
||||
// Start playback after seeking
|
||||
await player.play();
|
||||
|
||||
// Return result with available versions for UI updates
|
||||
return PlaybackInitializationResult(
|
||||
availableVersions: playbackData.availableVersions,
|
||||
);
|
||||
} catch (e) {
|
||||
if (e is PlaybackException) {
|
||||
rethrow;
|
||||
}
|
||||
throw PlaybackException(t.messages.errorLoading(error: e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Build list of external subtitle tracks from media info
|
||||
List<SubtitleTrack> _buildExternalSubtitles(PlexMediaInfo? mediaInfo) {
|
||||
final externalSubtitles = <SubtitleTrack>[];
|
||||
|
||||
if (mediaInfo == null) {
|
||||
return externalSubtitles;
|
||||
}
|
||||
|
||||
final externalTracks = mediaInfo.subtitleTracks
|
||||
.where((PlexSubtitleTrack track) => track.isExternal)
|
||||
.toList();
|
||||
|
||||
if (externalTracks.isNotEmpty) {
|
||||
appLogger.d('Found ${externalTracks.length} external subtitle track(s)');
|
||||
}
|
||||
|
||||
for (final plexTrack in externalTracks) {
|
||||
try {
|
||||
// Skip if no auth token is available
|
||||
final token = client.config.token;
|
||||
if (token == null) {
|
||||
appLogger.w('No auth token available for external subtitles');
|
||||
continue;
|
||||
}
|
||||
|
||||
final url = plexTrack.getSubtitleUrl(client.config.baseUrl, token);
|
||||
|
||||
// Skip if URL couldn't be constructed
|
||||
if (url == null) continue;
|
||||
|
||||
externalSubtitles.add(
|
||||
SubtitleTrack.uri(
|
||||
url,
|
||||
title:
|
||||
plexTrack.displayTitle ??
|
||||
plexTrack.language ??
|
||||
'Track ${plexTrack.id}',
|
||||
language: plexTrack.languageCode,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
// Silent fallback - log error but continue with other subtitles
|
||||
appLogger.w(
|
||||
'Failed to add external subtitle track ${plexTrack.id}',
|
||||
error: e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return externalSubtitles;
|
||||
}
|
||||
|
||||
/// Wait for media to be ready (duration > 0)
|
||||
Future<void> _waitForMediaReady() async {
|
||||
int attempts = 0;
|
||||
while (player.state.duration.inMilliseconds == 0 && attempts < 100) {
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
attempts++;
|
||||
}
|
||||
}
|
||||
|
||||
/// Add external subtitle tracks to the player without auto-selecting them
|
||||
Future<void> _addExternalSubtitles(
|
||||
List<SubtitleTrack> externalSubtitles,
|
||||
) async {
|
||||
appLogger.d(
|
||||
'Adding ${externalSubtitles.length} external subtitle(s) to player',
|
||||
);
|
||||
|
||||
final nativePlayer = player.platform as dynamic;
|
||||
|
||||
for (final subtitleTrack in externalSubtitles) {
|
||||
try {
|
||||
// Use mpv's sub-add with 'auto' flag to avoid auto-selection
|
||||
await nativePlayer.command([
|
||||
'sub-add',
|
||||
subtitleTrack.id,
|
||||
'auto',
|
||||
subtitleTrack.title ?? 'external',
|
||||
subtitleTrack.language ?? 'auto',
|
||||
]);
|
||||
} catch (e) {
|
||||
appLogger.w(
|
||||
'Failed to add external subtitle: ${subtitleTrack.title}',
|
||||
error: e,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of playback initialization
|
||||
class PlaybackInitializationResult {
|
||||
final List<dynamic> availableVersions;
|
||||
|
||||
PlaybackInitializationResult({required this.availableVersions});
|
||||
}
|
||||
|
||||
/// Exception thrown when playback initialization fails
|
||||
class PlaybackException implements Exception {
|
||||
final String message;
|
||||
|
||||
PlaybackException(this.message);
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
|
||||
import '../client/plex_client.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
|
||||
/// Tracks playback progress and reports it to the Plex server.
|
||||
///
|
||||
/// Handles:
|
||||
/// - Periodic timeline updates during playback
|
||||
/// - Resume position tracking
|
||||
/// - State change reporting (playing, paused, stopped)
|
||||
class PlaybackProgressTracker {
|
||||
final PlexClient client;
|
||||
final PlexMetadata metadata;
|
||||
final Player player;
|
||||
|
||||
/// Timer for periodic progress updates
|
||||
Timer? _progressTimer;
|
||||
|
||||
/// Update interval (default: 10 seconds)
|
||||
final Duration updateInterval;
|
||||
|
||||
PlaybackProgressTracker({
|
||||
required this.client,
|
||||
required this.metadata,
|
||||
required this.player,
|
||||
this.updateInterval = const Duration(seconds: 10),
|
||||
});
|
||||
|
||||
/// Start tracking playback progress
|
||||
///
|
||||
/// Begins periodic timeline updates to the Plex server.
|
||||
void startTracking() {
|
||||
if (_progressTimer != null) {
|
||||
appLogger.w('Progress tracking already started');
|
||||
return;
|
||||
}
|
||||
|
||||
_progressTimer = Timer.periodic(updateInterval, (timer) {
|
||||
if (player.state.playing) {
|
||||
_sendProgress('playing');
|
||||
}
|
||||
});
|
||||
|
||||
appLogger.d(
|
||||
'Started progress tracking (interval: ${updateInterval.inSeconds}s)',
|
||||
);
|
||||
}
|
||||
|
||||
/// Stop tracking playback progress
|
||||
///
|
||||
/// Cancels the periodic timer.
|
||||
void stopTracking() {
|
||||
_progressTimer?.cancel();
|
||||
_progressTimer = null;
|
||||
appLogger.d('Stopped progress tracking');
|
||||
}
|
||||
|
||||
/// Send progress update to Plex server
|
||||
///
|
||||
/// [state] can be 'playing', 'paused', or 'stopped'
|
||||
Future<void> sendProgress(String state) async {
|
||||
await _sendProgress(state);
|
||||
}
|
||||
|
||||
Future<void> _sendProgress(String state) async {
|
||||
try {
|
||||
final position = player.state.position;
|
||||
final duration = player.state.duration;
|
||||
|
||||
// Don't send progress if no duration (not ready)
|
||||
if (duration.inMilliseconds == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build timeline query parameters
|
||||
final queryParams = {
|
||||
'ratingKey': metadata.ratingKey,
|
||||
'key': '/library/metadata/${metadata.ratingKey}',
|
||||
'state': state,
|
||||
'time': position.inMilliseconds.toString(),
|
||||
'duration': duration.inMilliseconds.toString(),
|
||||
};
|
||||
|
||||
// Add playQueueItemID if available (for playlist/shuffle playback)
|
||||
if (metadata.playQueueItemID != null) {
|
||||
queryParams['playQueueItemID'] = metadata.playQueueItemID.toString();
|
||||
}
|
||||
|
||||
// Send timeline update
|
||||
await client
|
||||
.updateProgress(
|
||||
metadata.ratingKey,
|
||||
time: position.inMilliseconds,
|
||||
state: state,
|
||||
duration: duration.inMilliseconds,
|
||||
)
|
||||
.catchError((error) {
|
||||
// Silent handling - don't interrupt playback for progress errors
|
||||
appLogger.d(
|
||||
'Failed to update progress (non-critical)',
|
||||
error: error,
|
||||
);
|
||||
});
|
||||
|
||||
appLogger.d(
|
||||
'Progress update sent: $state at ${position.inSeconds}s / ${duration.inSeconds}s',
|
||||
);
|
||||
} catch (e) {
|
||||
appLogger.d('Failed to send progress update (non-critical)', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispose resources
|
||||
void dispose() {
|
||||
stopTracking();
|
||||
}
|
||||
}
|
||||