Merge branch 'edde746:main' into main
@@ -0,0 +1,49 @@
|
||||
# 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 `flutter analyze` before submitting
|
||||
- Test your changes thoroughly
|
||||
|
||||
## 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
|
||||
@@ -3,10 +3,9 @@
|
||||
<!-- Internet access permissions -->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
|
||||
<!-- Permissions for audio_service (OS media controls) -->
|
||||
<!-- 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"/>
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||
|
||||
<!-- Explicitly remove media permissions that media_kit may add -->
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" tools:node="remove" />
|
||||
@@ -45,27 +44,6 @@
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
|
||||
<!-- Audio service for OS media controls -->
|
||||
<service
|
||||
android:name="com.ryanheise.audioservice.AudioService"
|
||||
android:foregroundServiceType="mediaPlayback"
|
||||
android:exported="true"
|
||||
tools:ignore="Instantiatable">
|
||||
<intent-filter>
|
||||
<action android:name="android.media.browse.MediaBrowserService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<!-- Media button receiver for headset controls -->
|
||||
<receiver
|
||||
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
|
||||
android:exported="true"
|
||||
tools:ignore="Instantiatable">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
package com.edde746.plezy
|
||||
|
||||
import com.ryanheise.audioservice.AudioServiceFragmentActivity
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : AudioServiceFragmentActivity()
|
||||
class MainActivity : FlutterActivity()
|
||||
|
||||
|
Before Width: | Height: | Size: 952 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 688 B After Width: | Height: | Size: 820 B |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.8 KiB |
@@ -8,7 +8,7 @@
|
||||
</foreground>
|
||||
<monochrome>
|
||||
<inset
|
||||
android:drawable="@drawable/ic_launcher_monochrome"
|
||||
android:drawable="@mipmap/ic_launcher_monochrome"
|
||||
android:inset="16%" />
|
||||
</monochrome>
|
||||
</adaptive-icon>
|
||||
|
||||
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 16 KiB |
@@ -1,14 +1,11 @@
|
||||
PODS:
|
||||
- audio_service (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- audio_session (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):
|
||||
@@ -28,11 +25,10 @@ PODS:
|
||||
- Flutter
|
||||
|
||||
DEPENDENCIES:
|
||||
- audio_service (from `.symlinks/plugins/audio_service/darwin`)
|
||||
- audio_session (from `.symlinks/plugins/audio_session/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`)
|
||||
@@ -42,16 +38,14 @@ DEPENDENCIES:
|
||||
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
audio_service:
|
||||
:path: ".symlinks/plugins/audio_service/darwin"
|
||||
audio_session:
|
||||
:path: ".symlinks/plugins/audio_session/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:
|
||||
@@ -68,11 +62,10 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/wakelock_plus/ios"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
audio_service: aa99a6ba2ae7565996015322b0bb024e1d25c6fd
|
||||
audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0
|
||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||
media_kit_libs_ios_video: 5a18affdb97d1f5d466dc79988b13eff6c5e2854
|
||||
media_kit_video: 1746e198cb697d1ffb734b1d05ec429d1fcd1474
|
||||
os_media_controls: 86dceab6245a5325af90fc0fdebe243c42d789b4
|
||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
|
||||
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../config/plex_config.dart';
|
||||
import '../models/plex_library.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../models/plex_media_info.dart';
|
||||
import '../models/plex_file_info.dart';
|
||||
import '../models/plex_filter.dart';
|
||||
import '../models/plex_sort.dart';
|
||||
import '../models/plex_media_version.dart';
|
||||
import '../models/plex_hub.dart';
|
||||
import '../models/plex_library.dart';
|
||||
import '../models/plex_media_info.dart';
|
||||
import '../models/plex_media_version.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../models/plex_video_playback_data.dart';
|
||||
import '../models/plex_sort.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
|
||||
/// Result of testing a connection, including success status and latency
|
||||
@@ -496,6 +499,29 @@ class PlexClient {
|
||||
return [];
|
||||
}
|
||||
|
||||
Future<List<PlexMarker>> getMarkers(String ratingKey) async {
|
||||
final response = await _dio.get(
|
||||
'/library/metadata/$ratingKey',
|
||||
queryParameters: {'includeMarkers': 1},
|
||||
);
|
||||
|
||||
final metadataJson = _getFirstMetadataJson(response);
|
||||
|
||||
if (metadataJson != null && metadataJson['Marker'] != null) {
|
||||
final markerList = metadataJson['Marker'] as List;
|
||||
return markerList.map((marker) {
|
||||
return PlexMarker(
|
||||
id: marker['id'] as int,
|
||||
type: marker['type'] as String,
|
||||
startTimeOffset: marker['startTimeOffset'] as int,
|
||||
endTimeOffset: marker['endTimeOffset'] as int,
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Get detailed media info including chapters and tracks
|
||||
/// [mediaIndex] specifies which Media item to use (defaults to 0 - first version)
|
||||
Future<PlexMediaInfo?> getMediaInfo(
|
||||
@@ -614,6 +640,124 @@ class PlexClient {
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Get consolidated video playback data (URL, media info, and versions) in a single API call
|
||||
/// This method combines the functionality of getVideoUrl(), getMediaInfo(), and getMediaVersions()
|
||||
/// to reduce redundant API calls during video playback initialization.
|
||||
Future<PlexVideoPlaybackData> getVideoPlaybackData(
|
||||
String ratingKey, {
|
||||
int mediaIndex = 0,
|
||||
}) async {
|
||||
final response = await _dio.get('/library/metadata/$ratingKey');
|
||||
final metadataJson = _getFirstMetadataJson(response);
|
||||
|
||||
String? videoUrl;
|
||||
PlexMediaInfo? mediaInfo;
|
||||
List<PlexMediaVersion> availableVersions = [];
|
||||
|
||||
if (metadataJson != null &&
|
||||
metadataJson['Media'] != null &&
|
||||
(metadataJson['Media'] as List).isNotEmpty) {
|
||||
final mediaList = metadataJson['Media'] as List;
|
||||
|
||||
// Parse available media versions first
|
||||
availableVersions = mediaList
|
||||
.map(
|
||||
(media) => PlexMediaVersion.fromJson(media as Map<String, dynamic>),
|
||||
)
|
||||
.toList();
|
||||
|
||||
// Ensure the requested index is valid
|
||||
if (mediaIndex < 0 || mediaIndex >= mediaList.length) {
|
||||
mediaIndex = 0;
|
||||
}
|
||||
|
||||
final media = mediaList[mediaIndex];
|
||||
if (media['Part'] != null && (media['Part'] as List).isNotEmpty) {
|
||||
final part = media['Part'][0];
|
||||
final partKey = part['key'] as String?;
|
||||
|
||||
if (partKey != null) {
|
||||
// Get video URL
|
||||
videoUrl = '${config.baseUrl}$partKey?X-Plex-Token=${config.token}';
|
||||
|
||||
// Parse streams (audio and subtitle tracks) for media info
|
||||
final streams = part['Stream'] as List<dynamic>? ?? [];
|
||||
final audioTracks = <PlexAudioTrack>[];
|
||||
final subtitleTracks = <PlexSubtitleTrack>[];
|
||||
|
||||
for (var stream in streams) {
|
||||
final streamType = stream['streamType'] as int?;
|
||||
|
||||
if (streamType == 2) {
|
||||
// Audio track
|
||||
audioTracks.add(
|
||||
PlexAudioTrack(
|
||||
id: stream['id'] as int,
|
||||
index: stream['index'] as int?,
|
||||
codec: stream['codec'] as String?,
|
||||
language: stream['language'] as String?,
|
||||
languageCode: stream['languageCode'] as String?,
|
||||
title: stream['title'] as String?,
|
||||
displayTitle: stream['displayTitle'] as String?,
|
||||
channels: stream['channels'] as int?,
|
||||
selected: stream['selected'] == 1,
|
||||
),
|
||||
);
|
||||
} else if (streamType == 3) {
|
||||
// Subtitle track
|
||||
subtitleTracks.add(
|
||||
PlexSubtitleTrack(
|
||||
id: stream['id'] as int,
|
||||
index: stream['index'] as int?,
|
||||
codec: stream['codec'] as String?,
|
||||
language: stream['language'] as String?,
|
||||
languageCode: stream['languageCode'] as String?,
|
||||
title: stream['title'] as String?,
|
||||
displayTitle: stream['displayTitle'] as String?,
|
||||
selected: stream['selected'] == 1,
|
||||
forced: stream['forced'] == 1,
|
||||
key: stream['key'] as String?,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse chapters
|
||||
final chapters = <PlexChapter>[];
|
||||
if (metadataJson['Chapter'] != null) {
|
||||
final chapterList = metadataJson['Chapter'] as List<dynamic>;
|
||||
for (var chapter in chapterList) {
|
||||
chapters.add(
|
||||
PlexChapter(
|
||||
id: chapter['id'] as int,
|
||||
index: chapter['index'] as int?,
|
||||
startTimeOffset: chapter['startTimeOffset'] as int?,
|
||||
endTimeOffset: chapter['endTimeOffset'] as int?,
|
||||
title: chapter['title'] as String?,
|
||||
thumb: chapter['thumb'] as String?,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Create media info
|
||||
mediaInfo = PlexMediaInfo(
|
||||
videoUrl: videoUrl,
|
||||
audioTracks: audioTracks,
|
||||
subtitleTracks: subtitleTracks,
|
||||
chapters: chapters,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return PlexVideoPlaybackData(
|
||||
videoUrl: videoUrl,
|
||||
mediaInfo: mediaInfo,
|
||||
availableVersions: availableVersions,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get file information for a media item
|
||||
Future<PlexFileInfo?> getFileInfo(String ratingKey) async {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
{
|
||||
"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"
|
||||
},
|
||||
"screens": {
|
||||
"licenses": "Licenses",
|
||||
"selectServer": "Select Server",
|
||||
"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",
|
||||
"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"
|
||||
},
|
||||
"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",
|
||||
"goToSeries": "Go to series",
|
||||
"goToSeason": "Go to season",
|
||||
"shufflePlay": "Shuffle Play",
|
||||
"fileInfo": "File Info"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"userStatus": {
|
||||
"admin": "Admin",
|
||||
"restricted": "Restricted",
|
||||
"protected": "Protected"
|
||||
},
|
||||
"messages": {
|
||||
"markedAsWatched": "Marked as watched",
|
||||
"markedAsUnwatched": "Marked as unwatched",
|
||||
"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}"
|
||||
},
|
||||
"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",
|
||||
"switchServer": "Switch Server",
|
||||
"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",
|
||||
"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"
|
||||
},
|
||||
"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...",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
{
|
||||
"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"
|
||||
},
|
||||
"screens": {
|
||||
"licenses": "Licenze",
|
||||
"selectServer": "Seleziona server",
|
||||
"switchProfile": "Cambia profilo",
|
||||
"subtitleStyling": "Stile sottotitoli",
|
||||
"search": "Cerca",
|
||||
"logs": "Logs"
|
||||
},
|
||||
"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",
|
||||
"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"
|
||||
},
|
||||
"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",
|
||||
"goToSeries": "Vai alle serie",
|
||||
"goToSeason": "Vai alla stagione",
|
||||
"shufflePlay": "Riproduzione casuale",
|
||||
"fileInfo": "Info sul file"
|
||||
},
|
||||
"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": "No offset",
|
||||
"letterbox": "Letterbox",
|
||||
"fillScreen": "Riempi schermo",
|
||||
"stretch": "Allunga",
|
||||
"lockRotation": "Blocca rotazione",
|
||||
"unlockRotation": "Sblocca rotazione"
|
||||
},
|
||||
"userStatus": {
|
||||
"admin": "Admin",
|
||||
"restricted": "Limitato",
|
||||
"protected": "Protetto"
|
||||
},
|
||||
"messages": {
|
||||
"markedAsWatched": "Segna come visto",
|
||||
"markedAsUnwatched": "Segna come non visto",
|
||||
"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}"
|
||||
},
|
||||
"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": "Discover",
|
||||
"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",
|
||||
"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"
|
||||
},
|
||||
"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...",
|
||||
"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": "Log",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
{
|
||||
"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"
|
||||
},
|
||||
"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",
|
||||
"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"
|
||||
},
|
||||
"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",
|
||||
"goToSeries": "Gå till serie",
|
||||
"goToSeason": "Gå till säsong",
|
||||
"shufflePlay": "Blanda uppspelning",
|
||||
"fileInfo": "Filinformation"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"userStatus": {
|
||||
"admin": "Admin",
|
||||
"restricted": "Begränsad",
|
||||
"protected": "Skyddad"
|
||||
},
|
||||
"messages": {
|
||||
"markedAsWatched": "Markerad som sedd",
|
||||
"markedAsUnwatched": "Markerad som osedd",
|
||||
"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}"
|
||||
},
|
||||
"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",
|
||||
"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"
|
||||
},
|
||||
"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...",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import 'services/macos_titlebar_service.dart';
|
||||
import 'services/fullscreen_state_manager.dart';
|
||||
import 'services/update_service.dart';
|
||||
import 'services/settings_service.dart';
|
||||
import 'services/media_service_manager.dart';
|
||||
import 'providers/user_profile_provider.dart';
|
||||
import 'providers/plex_client_provider.dart';
|
||||
import 'providers/theme_provider.dart';
|
||||
@@ -24,10 +23,18 @@ 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
|
||||
@@ -43,9 +50,6 @@ void main() async {
|
||||
// Initialize MediaKit
|
||||
MediaKit.ensureInitialized();
|
||||
|
||||
// Initialize OS media controls
|
||||
await MediaServiceManager.instance.initialize();
|
||||
|
||||
// Note: Orientation will be set dynamically based on device type in MainApp
|
||||
|
||||
await StorageService.getInstance();
|
||||
@@ -54,7 +58,6 @@ void main() async {
|
||||
await LanguageCodes.initialize();
|
||||
|
||||
// Initialize logger level based on debug setting
|
||||
final settings = await SettingsService.getInstance();
|
||||
final debugEnabled = settings.getEnableDebugLogging();
|
||||
setLoggerLevel(debugEnabled);
|
||||
|
||||
@@ -87,14 +90,16 @@ class MainApp extends StatelessWidget {
|
||||
],
|
||||
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(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -162,18 +167,18 @@ class _SetupScreenState extends State<SetupScreen> {
|
||||
context: context,
|
||||
builder: (BuildContext dialogContext) {
|
||||
return AlertDialog(
|
||||
title: const Text('Update Available'),
|
||||
title: Text(t.update.available),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Version ${updateInfo['latestVersion']} is available',
|
||||
t.update.versionAvailable(version: updateInfo['latestVersion']),
|
||||
style: Theme.of(dialogContext).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Current: ${updateInfo['currentVersion']}',
|
||||
t.update.currentVersion(version: updateInfo['currentVersion']),
|
||||
style: Theme.of(dialogContext).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
@@ -183,7 +188,7 @@ class _SetupScreenState extends State<SetupScreen> {
|
||||
onPressed: () {
|
||||
Navigator.pop(dialogContext);
|
||||
},
|
||||
child: const Text('Later'),
|
||||
child: Text(t.common.later),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
@@ -192,7 +197,7 @@ class _SetupScreenState extends State<SetupScreen> {
|
||||
Navigator.pop(dialogContext);
|
||||
}
|
||||
},
|
||||
child: const Text('Skip This Version'),
|
||||
child: Text(t.update.skipVersion),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () async {
|
||||
@@ -204,7 +209,7 @@ class _SetupScreenState extends State<SetupScreen> {
|
||||
Navigator.pop(dialogContext);
|
||||
}
|
||||
},
|
||||
child: const Text('View Release'),
|
||||
child: Text(t.update.viewRelease),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -299,14 +304,14 @@ class _SetupScreenState extends State<SetupScreen> {
|
||||
|
||||
@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),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -13,6 +13,7 @@ class PlexLibrary {
|
||||
final String? uuid;
|
||||
final int? updatedAt;
|
||||
final int? createdAt;
|
||||
final int? hidden;
|
||||
|
||||
PlexLibrary({
|
||||
required this.key,
|
||||
@@ -24,6 +25,7 @@ class PlexLibrary {
|
||||
this.uuid,
|
||||
this.updatedAt,
|
||||
this.createdAt,
|
||||
this.hidden,
|
||||
});
|
||||
|
||||
factory PlexLibrary.fromJson(Map<String, dynamic> json) =>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -146,3 +146,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,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;
|
||||
}
|
||||
@@ -25,7 +25,8 @@ class PlaybackStateProvider with ChangeNotifier {
|
||||
|
||||
/// Gets the next episode in the shuffle queue.
|
||||
/// Returns null if queue is exhausted or current episode is not in queue.
|
||||
PlexMetadata? getNextEpisode(String currentEpisodeKey) {
|
||||
/// [loopQueue] - If true, restart from beginning when queue is exhausted
|
||||
PlexMetadata? getNextEpisode(String currentEpisodeKey, {bool loopQueue = false}) {
|
||||
if (_shuffleQueue.isEmpty) return null;
|
||||
|
||||
// Find current episode in queue
|
||||
@@ -42,6 +43,11 @@ class PlaybackStateProvider with ChangeNotifier {
|
||||
// Check if there's a next episode
|
||||
if (currentIndex + 1 >= _shuffleQueue.length) {
|
||||
// Queue exhausted
|
||||
if (loopQueue && _shuffleQueue.isNotEmpty) {
|
||||
// Loop back to beginning
|
||||
_currentIndex = 0;
|
||||
return _shuffleQueue[_currentIndex];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -49,6 +55,31 @@ class PlaybackStateProvider with ChangeNotifier {
|
||||
return _shuffleQueue[_currentIndex];
|
||||
}
|
||||
|
||||
/// Gets the previous episode in the shuffle queue.
|
||||
/// Returns null if at the beginning of the queue or current episode is not in queue.
|
||||
PlexMetadata? getPreviousEpisode(String currentEpisodeKey) {
|
||||
if (_shuffleQueue.isEmpty) return null;
|
||||
|
||||
// Find current episode in queue
|
||||
final currentIndex = _shuffleQueue.indexWhere(
|
||||
(ep) => ep.ratingKey == currentEpisodeKey,
|
||||
);
|
||||
|
||||
if (currentIndex == -1) {
|
||||
// Current episode not in queue
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if there's a previous episode
|
||||
if (currentIndex <= 0) {
|
||||
// At the beginning of queue
|
||||
return null;
|
||||
}
|
||||
|
||||
_currentIndex = currentIndex - 1;
|
||||
return _shuffleQueue[_currentIndex];
|
||||
}
|
||||
|
||||
/// Clears the shuffle queue and exits shuffle mode
|
||||
void clearShuffle() {
|
||||
_shuffleQueue = [];
|
||||
|
||||
@@ -7,6 +7,9 @@ class SettingsProvider extends ChangeNotifier {
|
||||
ViewMode _viewMode = ViewMode.grid;
|
||||
bool _useSeasonPoster = false;
|
||||
bool _showHeroSection = true;
|
||||
bool _shuffleUnwatchedOnly = true;
|
||||
bool _shuffleOrderNavigation = true;
|
||||
bool _shuffleLoopQueue = false;
|
||||
|
||||
SettingsProvider() {
|
||||
_initializeSettings();
|
||||
@@ -18,6 +21,9 @@ class SettingsProvider extends ChangeNotifier {
|
||||
_viewMode = _settingsService.getViewMode();
|
||||
_useSeasonPoster = _settingsService.getUseSeasonPoster();
|
||||
_showHeroSection = _settingsService.getShowHeroSection();
|
||||
_shuffleUnwatchedOnly = _settingsService.getShuffleUnwatchedOnly();
|
||||
_shuffleOrderNavigation = _settingsService.getShuffleOrderNavigation();
|
||||
_shuffleLoopQueue = _settingsService.getShuffleLoopQueue();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -25,6 +31,9 @@ class SettingsProvider extends ChangeNotifier {
|
||||
ViewMode get viewMode => _viewMode;
|
||||
bool get useSeasonPoster => _useSeasonPoster;
|
||||
bool get showHeroSection => _showHeroSection;
|
||||
bool get shuffleUnwatchedOnly => _shuffleUnwatchedOnly;
|
||||
bool get shuffleOrderNavigation => _shuffleOrderNavigation;
|
||||
bool get shuffleLoopQueue => _shuffleLoopQueue;
|
||||
|
||||
Future<void> setLibraryDensity(LibraryDensity density) async {
|
||||
if (_libraryDensity != density) {
|
||||
@@ -58,6 +67,30 @@ class SettingsProvider extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setShuffleUnwatchedOnly(bool value) async {
|
||||
if (_shuffleUnwatchedOnly != value) {
|
||||
_shuffleUnwatchedOnly = value;
|
||||
await _settingsService.setShuffleUnwatchedOnly(value);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setShuffleOrderNavigation(bool value) async {
|
||||
if (_shuffleOrderNavigation != value) {
|
||||
_shuffleOrderNavigation = value;
|
||||
await _settingsService.setShuffleOrderNavigation(value);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setShuffleLoopQueue(bool value) async {
|
||||
if (_shuffleLoopQueue != value) {
|
||||
_shuffleLoopQueue = value;
|
||||
await _settingsService.setShuffleLoopQueue(value);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
String get libraryDensityDisplayName {
|
||||
switch (_libraryDensity) {
|
||||
case LibraryDensity.compact:
|
||||
|
||||
@@ -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,9 +77,9 @@ 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: () {
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 '../i18n/strings.g.dart';
|
||||
import 'server_selection_screen.dart';
|
||||
|
||||
class AuthScreen extends StatefulWidget {
|
||||
@@ -63,7 +64,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.inAppBrowserView);
|
||||
} else {
|
||||
throw Exception('Could not launch auth URL');
|
||||
throw Exception(t.errors.couldNotLaunchUrl);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +82,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
if (token == null) {
|
||||
setState(() {
|
||||
_isAuthenticating = false;
|
||||
_errorMessage = 'Authentication timed out. Please try again.';
|
||||
_errorMessage = t.auth.authenticationTimeout;
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -119,7 +120,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_isAuthenticating = false;
|
||||
_errorMessage = 'Authentication failed: $e';
|
||||
_errorMessage = t.errors.authenticationFailed(error: e);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -149,15 +150,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(),
|
||||
),
|
||||
@@ -169,14 +170,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;
|
||||
}
|
||||
@@ -187,7 +188,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
final isValid = await _authService.verifyToken(token);
|
||||
if (!isValid) {
|
||||
setDialogState(() {
|
||||
errorMessage = 'Invalid token';
|
||||
errorMessage = t.errors.invalidToken;
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -210,11 +211,11 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
}
|
||||
} catch (e) {
|
||||
setDialogState(() {
|
||||
errorMessage = 'Failed to verify token: $e';
|
||||
errorMessage = t.errors.failedToVerifyToken(error: e);
|
||||
});
|
||||
}
|
||||
},
|
||||
child: const Text('Authenticate'),
|
||||
child: Text(t.auth.authenticate),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -238,7 +239,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
Image.asset('assets/plezy.png', width: 120, height: 120),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Plezy',
|
||||
t.app.title,
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
@@ -250,8 +251,8 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_useQrFlow
|
||||
? 'Scan this QR code with a device logged into Plex to authenticate.'
|
||||
: 'Waiting for authentication...\nPlease complete sign-in in your browser.',
|
||||
? t.auth.scanQRCodeInstruction
|
||||
: t.auth.waitingForAuth,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.grey),
|
||||
),
|
||||
@@ -281,7 +282,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
horizontal: 24,
|
||||
),
|
||||
),
|
||||
child: const Text('Retry'),
|
||||
child: Text(t.auth.retry),
|
||||
),
|
||||
] else ...[ // add QR button here
|
||||
ElevatedButton(
|
||||
@@ -289,7 +290,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
child: const Text('Sign in with Plex'),
|
||||
child: Text(t.auth.signInWithPlex),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton(
|
||||
@@ -302,7 +303,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
child: const Text('Show QR Code'),
|
||||
child: Text(t.auth.showQRCode),
|
||||
),
|
||||
if (kDebugMode) ...[
|
||||
const SizedBox(height: 12),
|
||||
@@ -316,8 +317,8 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
).colorScheme.outline.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'Debug: Enter Token',
|
||||
child: Text(
|
||||
t.auth.debugEnterToken,
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -18,6 +18,7 @@ import 'hub_detail_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';
|
||||
@@ -42,7 +43,6 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
PlexClient get client => context.clientSafe;
|
||||
|
||||
List<PlexMetadata> _onDeck = [];
|
||||
List<PlexMetadata> _recentlyAdded = [];
|
||||
List<PlexHub> _hubs = [];
|
||||
bool _isLoading = true;
|
||||
String? _errorMessage;
|
||||
@@ -167,7 +167,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
});
|
||||
|
||||
try {
|
||||
appLogger.d('Fetching onDeck and recentlyAdded from Plex');
|
||||
appLogger.d('Fetching onDeck and hubs from Plex');
|
||||
final clientProvider = context.plexClient;
|
||||
final client = clientProvider.client;
|
||||
if (client == null) {
|
||||
@@ -175,48 +175,45 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
}
|
||||
|
||||
final onDeck = await client.getOnDeck();
|
||||
final recentlyAdded = await client.getRecentlyAdded(limit: 20);
|
||||
|
||||
// Load hubs from all libraries
|
||||
final libraries = await client.getLibraries();
|
||||
final allHubs = <PlexHub>[];
|
||||
|
||||
for (final library in libraries) {
|
||||
// Only fetch hubs for movie and show libraries
|
||||
if (library.type == 'movie' || library.type == 'show') {
|
||||
try {
|
||||
final libraryHubs = await client.getLibraryHubs(
|
||||
library.key,
|
||||
limit: 12,
|
||||
);
|
||||
// Filter out duplicate hubs that we already fetch separately
|
||||
final filteredHubs = libraryHubs.where((hub) {
|
||||
final hubId = hub.hubIdentifier?.toLowerCase() ?? '';
|
||||
final title = hub.title.toLowerCase();
|
||||
// Skip "Continue Watching", "On Deck", and "Recently Added" hubs
|
||||
return !hubId.contains('ondeck') &&
|
||||
!hubId.contains('continue') &&
|
||||
!hubId.contains('recentlyadded') &&
|
||||
!title.contains('continue watching') &&
|
||||
!title.contains('on deck') &&
|
||||
!title.contains('recently added');
|
||||
}).toList();
|
||||
allHubs.addAll(filteredHubs);
|
||||
} catch (e) {
|
||||
appLogger.w(
|
||||
'Failed to load hubs for library ${library.title}',
|
||||
error: e,
|
||||
);
|
||||
}
|
||||
// Skip libraries that are not movie/show or are hidden
|
||||
if (library.type != 'movie' && library.type != 'show') continue;
|
||||
if (library.hidden != 0) continue;
|
||||
|
||||
try {
|
||||
final libraryHubs = await client.getLibraryHubs(
|
||||
library.key,
|
||||
limit: 12,
|
||||
);
|
||||
// Filter out duplicate hubs that we already fetch separately
|
||||
final filteredHubs = libraryHubs.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();
|
||||
allHubs.addAll(filteredHubs);
|
||||
} catch (e) {
|
||||
appLogger.w(
|
||||
'Failed to load hubs for library ${library.title}',
|
||||
error: e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
appLogger.d(
|
||||
'Received ${onDeck.length} on deck items, ${recentlyAdded.length} recently added items, and ${allHubs.length} hubs',
|
||||
'Received ${onDeck.length} on deck items and ${allHubs.length} hubs',
|
||||
);
|
||||
setState(() {
|
||||
_onDeck = onDeck;
|
||||
_recentlyAdded = recentlyAdded;
|
||||
_hubs = allHubs;
|
||||
_isLoading = false;
|
||||
|
||||
@@ -273,7 +270,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
}
|
||||
}
|
||||
|
||||
// Public method to refresh content
|
||||
// Public method to refresh content (for normal navigation)
|
||||
@override
|
||||
void refresh() {
|
||||
appLogger.d('DiscoverScreen.refresh() called');
|
||||
@@ -281,6 +278,13 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
_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();
|
||||
@@ -391,12 +395,14 @@ 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;
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,8 +413,8 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
if (plexToken == null) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('No Plex token found. Please login again.'),
|
||||
SnackBar(
|
||||
content: Text(t.messages.noPlexToken),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -433,7 +439,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to initialize server selection: $e')),
|
||||
SnackBar(content: Text(t.messages.errorLoading(error: e.toString()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -443,16 +449,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),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -497,7 +503,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,
|
||||
@@ -531,33 +537,33 @@ 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(
|
||||
PopupMenuItem(
|
||||
value: 'switch_server',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.swap_horiz),
|
||||
SizedBox(width: 8),
|
||||
Text('Switch Server'),
|
||||
Text(t.discover.switchServer),
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
PopupMenuItem(
|
||||
value: 'logout',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.logout),
|
||||
SizedBox(width: 8),
|
||||
Text('Logout'),
|
||||
Text(t.discover.logout),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -587,7 +593,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _loadContent,
|
||||
child: const Text('Retry'),
|
||||
child: Text(t.common.retry),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -614,7 +620,7 @@ 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,
|
||||
),
|
||||
],
|
||||
@@ -624,25 +630,6 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
_buildHorizontalList(_onDeck, isLarge: false),
|
||||
],
|
||||
|
||||
// Recently Added
|
||||
if (_recentlyAdded.isNotEmpty) ...[
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildHorizontalList(_recentlyAdded, isLarge: false),
|
||||
],
|
||||
|
||||
// Recommendation Hubs (Trending, Top in Genre, etc.)
|
||||
for (final hub in _hubs) ...[
|
||||
@@ -683,8 +670,8 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
_buildHorizontalList(hub.items, isLarge: false),
|
||||
],
|
||||
|
||||
if (_onDeck.isEmpty && _recentlyAdded.isEmpty && _hubs.isEmpty)
|
||||
const SliverFillRemaining(
|
||||
if (_onDeck.isEmpty && _hubs.isEmpty)
|
||||
SliverFillRemaining(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
@@ -695,10 +682,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),
|
||||
),
|
||||
],
|
||||
@@ -758,7 +745,7 @@ 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
|
||||
@@ -848,8 +835,8 @@ 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;
|
||||
|
||||
return Semantics(
|
||||
label: "media-hero-${heroItem.ratingKey}",
|
||||
@@ -1224,7 +1211,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,
|
||||
@@ -1232,8 +1219,8 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
),
|
||||
),
|
||||
] else
|
||||
const Text(
|
||||
'Play',
|
||||
Text(
|
||||
t.discover.play,
|
||||
style: TextStyle(
|
||||
color: Colors.black,
|
||||
fontSize: 14,
|
||||
|
||||
@@ -12,6 +12,7 @@ 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 {
|
||||
@@ -99,23 +100,23 @@ class _HubDetailScreenState extends State<HubDetailScreen> with Refreshable {
|
||||
|
||||
List<PlexSort> _getDefaultSortOptions() {
|
||||
return [
|
||||
PlexSort(key: 'titleSort', title: 'Title', defaultDirection: 'asc'),
|
||||
PlexSort(key: 'titleSort', title: t.hubDetail.title, defaultDirection: 'asc'),
|
||||
PlexSort(
|
||||
key: 'year',
|
||||
descKey: 'year:desc',
|
||||
title: 'Release Year',
|
||||
title: t.hubDetail.releaseYear,
|
||||
defaultDirection: 'desc',
|
||||
),
|
||||
PlexSort(
|
||||
key: 'addedAt',
|
||||
descKey: 'addedAt:desc',
|
||||
title: 'Date Added',
|
||||
title: t.hubDetail.dateAdded,
|
||||
defaultDirection: 'desc',
|
||||
),
|
||||
PlexSort(
|
||||
key: 'rating',
|
||||
descKey: 'rating:desc',
|
||||
title: 'Rating',
|
||||
title: t.hubDetail.rating,
|
||||
defaultDirection: 'desc',
|
||||
),
|
||||
];
|
||||
@@ -216,7 +217,7 @@ class _HubDetailScreenState extends State<HubDetailScreen> with Refreshable {
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to load hub content', error: e);
|
||||
setState(() {
|
||||
_errorMessage = 'Failed to load content: $e';
|
||||
_errorMessage = t.messages.errorLoading(error: e.toString());
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
@@ -248,7 +249,7 @@ class _HubDetailScreenState extends State<HubDetailScreen> with Refreshable {
|
||||
pinned: true,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.swap_vert, semanticLabel: 'Sort'),
|
||||
icon: Icon(Icons.swap_vert, semanticLabel: t.libraries.sort),
|
||||
onPressed: _showSortBottomSheet,
|
||||
),
|
||||
],
|
||||
@@ -269,7 +270,7 @@ class _HubDetailScreenState extends State<HubDetailScreen> with Refreshable {
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _loadMoreItems,
|
||||
child: const Text('Retry'),
|
||||
child: Text(t.common.retry),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -280,8 +281,8 @@ class _HubDetailScreenState extends State<HubDetailScreen> with Refreshable {
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
else if (_filteredItems.isEmpty)
|
||||
const SliverFillRemaining(
|
||||
child: Center(child: Text('No items found')),
|
||||
SliverFillRemaining(
|
||||
child: Center(child: Text(t.hubDetail.noItemsFound)),
|
||||
)
|
||||
else
|
||||
SliverPadding(
|
||||
|
||||
@@ -20,6 +20,7 @@ import '../services/settings_service.dart';
|
||||
import '../mixins/refreshable.dart';
|
||||
import '../mixins/item_updatable.dart';
|
||||
import '../theme/theme_helper.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
|
||||
class LibrariesScreen extends StatefulWidget {
|
||||
const LibrariesScreen({super.key});
|
||||
@@ -66,18 +67,18 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
switch (error.type) {
|
||||
case DioExceptionType.connectionTimeout:
|
||||
case DioExceptionType.receiveTimeout:
|
||||
return 'Connection timeout while loading $context';
|
||||
return t.errors.connectionTimeout(context: context);
|
||||
case DioExceptionType.connectionError:
|
||||
return 'Unable to connect to Plex server';
|
||||
return t.errors.connectionFailed;
|
||||
default:
|
||||
appLogger.e('Error loading $context', error: error);
|
||||
return 'Failed to load $context: ${error.message}';
|
||||
return t.errors.failedToLoad(context: context, error: error.message ?? 'Unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
// Generic error
|
||||
appLogger.e('Unexpected error in $context', error: error);
|
||||
return 'Failed to load $context: $error';
|
||||
return t.errors.failedToLoad(context: context, error: error.toString());
|
||||
}
|
||||
|
||||
Future<void> _loadLibraries() async {
|
||||
@@ -99,7 +100,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
try {
|
||||
final client = clientProvider.client;
|
||||
if (client == null) {
|
||||
throw Exception('No client available');
|
||||
throw Exception(t.errors.noClientAvailable);
|
||||
}
|
||||
|
||||
final storage = await StorageService.getInstance();
|
||||
@@ -233,7 +234,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
final client = clientProvider.client;
|
||||
if (client == null) {
|
||||
setState(() {
|
||||
_errorMessage = 'No client available';
|
||||
_errorMessage = t.errors.noClientAvailable;
|
||||
_isLoadingItems = false;
|
||||
});
|
||||
return;
|
||||
@@ -364,7 +365,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
);
|
||||
final client = clientProvider.client;
|
||||
if (client == null) {
|
||||
throw Exception('No client available');
|
||||
throw Exception(t.errors.noClientAvailable);
|
||||
}
|
||||
|
||||
final filters = await client.getLibraryFilters(libraryKey);
|
||||
@@ -387,7 +388,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
);
|
||||
final client = clientProvider.client;
|
||||
if (client == null) {
|
||||
throw Exception('No client available');
|
||||
throw Exception(t.errors.noClientAvailable);
|
||||
}
|
||||
|
||||
final sortOptions = await client.getLibrarySorts(libraryKey);
|
||||
@@ -449,7 +450,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
);
|
||||
final client = clientProvider.client;
|
||||
if (client == null) {
|
||||
throw Exception('No client available');
|
||||
throw Exception(t.errors.noClientAvailable);
|
||||
}
|
||||
|
||||
// Add sort parameter to filters if selected
|
||||
@@ -474,7 +475,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_errorMessage = 'Failed to load library content: $e';
|
||||
_errorMessage = t.messages.errorLoading(error: e.toString());
|
||||
_isLoadingItems = false;
|
||||
});
|
||||
}
|
||||
@@ -503,12 +504,22 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
}
|
||||
}
|
||||
|
||||
// Public method to refresh content
|
||||
// Public method to refresh content (for normal navigation)
|
||||
@override
|
||||
void refresh() {
|
||||
_loadLibraries();
|
||||
}
|
||||
|
||||
// Public method to fully reload all content (for profile switches)
|
||||
void fullRefresh() {
|
||||
appLogger.d('LibrariesScreen.fullRefresh() called - reloading all content');
|
||||
// Reload libraries and clear any selected library/filters
|
||||
_selectedLibraryKey = null;
|
||||
_selectedFilters.clear();
|
||||
_items.clear();
|
||||
_loadLibraries();
|
||||
}
|
||||
|
||||
Future<void> _toggleLibraryVisibility(PlexLibrary library) async {
|
||||
final hiddenLibrariesProvider = Provider.of<HiddenLibrariesProvider>(
|
||||
context,
|
||||
@@ -587,39 +598,39 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
ContextMenuItem(
|
||||
value: 'scan',
|
||||
icon: Icons.refresh,
|
||||
label: 'Scan Library Files',
|
||||
label: t.libraries.scanLibraryFiles,
|
||||
requiresConfirmation: true,
|
||||
confirmationTitle: 'Scan Library',
|
||||
confirmationTitle: t.libraries.scanLibrary,
|
||||
confirmationMessage:
|
||||
'This will scan "${library.title}" for new files. Continue?',
|
||||
t.libraries.scanLibraryConfirm(title: library.title),
|
||||
),
|
||||
ContextMenuItem(
|
||||
value: 'analyze',
|
||||
icon: Icons.analytics_outlined,
|
||||
label: 'Analyze',
|
||||
label: t.libraries.analyze,
|
||||
requiresConfirmation: true,
|
||||
confirmationTitle: 'Analyze Library',
|
||||
confirmationTitle: t.libraries.analyzeLibrary,
|
||||
confirmationMessage:
|
||||
'This will analyze "${library.title}" for intro markers and other metadata. This may take some time. Continue?',
|
||||
t.libraries.analyzeLibraryConfirm(title: library.title),
|
||||
),
|
||||
ContextMenuItem(
|
||||
value: 'refresh',
|
||||
icon: Icons.sync,
|
||||
label: 'Refresh Metadata',
|
||||
label: t.libraries.refreshMetadata,
|
||||
requiresConfirmation: true,
|
||||
confirmationTitle: 'Refresh Metadata',
|
||||
confirmationTitle: t.libraries.refreshMetadata,
|
||||
confirmationMessage:
|
||||
'This will refresh metadata for all items in "${library.title}". This may take some time. Continue?',
|
||||
t.libraries.refreshMetadataConfirm(title: library.title),
|
||||
isDestructive: true,
|
||||
),
|
||||
ContextMenuItem(
|
||||
value: 'empty_trash',
|
||||
icon: Icons.delete_outline,
|
||||
label: 'Empty Trash',
|
||||
label: t.libraries.emptyTrash,
|
||||
requiresConfirmation: true,
|
||||
confirmationTitle: 'Empty Trash',
|
||||
confirmationTitle: t.libraries.emptyTrash,
|
||||
confirmationMessage:
|
||||
'This will permanently delete all trashed items in "${library.title}". This action cannot be undone. Continue?',
|
||||
t.libraries.emptyTrashConfirm(title: library.title),
|
||||
isDestructive: true,
|
||||
),
|
||||
];
|
||||
@@ -672,14 +683,14 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
final clientProvider = context.plexClient;
|
||||
final client = clientProvider.client;
|
||||
if (client == null) {
|
||||
throw Exception('No client available');
|
||||
throw Exception(t.errors.noClientAvailable);
|
||||
}
|
||||
|
||||
// Show progress indicator
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Scanning "${library.title}"...'),
|
||||
content: Text(t.messages.libraryScanning(title: library.title)),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
@@ -690,7 +701,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Library scan started for "${library.title}"'),
|
||||
content: Text(t.messages.libraryScanStarted(title: library.title)),
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
@@ -700,7 +711,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to scan library: $e'),
|
||||
content: Text(t.messages.libraryScanFailed(error: e.toString())),
|
||||
backgroundColor: Colors.red,
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
@@ -714,14 +725,14 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
final clientProvider = context.plexClient;
|
||||
final client = clientProvider.client;
|
||||
if (client == null) {
|
||||
throw Exception('No client available');
|
||||
throw Exception(t.errors.noClientAvailable);
|
||||
}
|
||||
|
||||
// Show progress indicator
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Refreshing metadata for "${library.title}"...'),
|
||||
content: Text(t.messages.metadataRefreshing(title: library.title)),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
@@ -732,7 +743,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Metadata refresh started for "${library.title}"'),
|
||||
content: Text(t.messages.metadataRefreshStarted(title: library.title)),
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
@@ -742,7 +753,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to refresh metadata: $e'),
|
||||
content: Text(t.messages.metadataRefreshFailed(error: e.toString())),
|
||||
backgroundColor: Colors.red,
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
@@ -756,14 +767,14 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
final clientProvider = context.plexClient;
|
||||
final client = clientProvider.client;
|
||||
if (client == null) {
|
||||
throw Exception('No client available');
|
||||
throw Exception(t.errors.noClientAvailable);
|
||||
}
|
||||
|
||||
// Show progress indicator
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Emptying trash for "${library.title}"...'),
|
||||
content: Text(t.libraries.emptyingTrash(title: library.title)),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
@@ -774,7 +785,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Trash emptied for "${library.title}"'),
|
||||
content: Text(t.libraries.trashEmptied(title: library.title)),
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
@@ -784,7 +795,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to empty trash: $e'),
|
||||
content: Text(t.libraries.failedToEmptyTrash(error: e)),
|
||||
backgroundColor: Colors.red,
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
@@ -798,14 +809,14 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
final clientProvider = context.plexClient;
|
||||
final client = clientProvider.client;
|
||||
if (client == null) {
|
||||
throw Exception('No client available');
|
||||
throw Exception(t.errors.noClientAvailable);
|
||||
}
|
||||
|
||||
// Show progress indicator
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Analyzing "${library.title}"...'),
|
||||
content: Text(t.libraries.analyzing(title: library.title)),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
@@ -816,7 +827,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Analysis started for "${library.title}"'),
|
||||
content: Text(t.libraries.analysisStarted(title: library.title)),
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
@@ -826,7 +837,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to analyze library: $e'),
|
||||
content: Text(t.libraries.failedToAnalyze(error: e)),
|
||||
backgroundColor: Colors.red,
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
@@ -850,7 +861,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
DesktopSliverAppBar(
|
||||
title: const Text('Libraries'),
|
||||
title: Text(t.libraries.title),
|
||||
floating: true,
|
||||
pinned: true,
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
@@ -860,15 +871,15 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
actions: [
|
||||
if (_allLibraries.isNotEmpty)
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
icon: Icon(
|
||||
Icons.edit,
|
||||
semanticLabel: 'Manage Libraries',
|
||||
semanticLabel: t.libraries.manageLibraries,
|
||||
),
|
||||
onPressed: _showLibraryManagementSheet,
|
||||
),
|
||||
if (_sortOptions.isNotEmpty)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.swap_vert, semanticLabel: 'Sort'),
|
||||
icon: Icon(Icons.swap_vert, semanticLabel: t.libraries.sort),
|
||||
onPressed: _showSortBottomSheet,
|
||||
),
|
||||
if (_filters.isNotEmpty)
|
||||
@@ -876,15 +887,15 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
icon: Badge(
|
||||
label: Text('${_selectedFilters.length}'),
|
||||
isLabelVisible: _selectedFilters.isNotEmpty,
|
||||
child: const Icon(
|
||||
child: Icon(
|
||||
Icons.filter_list,
|
||||
semanticLabel: 'Filters',
|
||||
semanticLabel: t.libraries.filters,
|
||||
),
|
||||
),
|
||||
onPressed: _showFiltersBottomSheet,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh, semanticLabel: 'Refresh'),
|
||||
icon: Icon(Icons.refresh, semanticLabel: t.common.refresh),
|
||||
onPressed: () => _loadLibraryContent(_selectedLibraryKey!),
|
||||
),
|
||||
],
|
||||
@@ -909,25 +920,25 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _loadLibraries,
|
||||
child: const Text('Retry'),
|
||||
child: Text(t.common.retry),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (visibleLibraries.isEmpty)
|
||||
const SliverFillRemaining(
|
||||
SliverFillRemaining(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
const Icon(
|
||||
Icons.video_library_outlined,
|
||||
size: 64,
|
||||
color: Colors.grey,
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
Text('No libraries found'),
|
||||
const SizedBox(height: 16),
|
||||
Text(t.libraries.noLibrariesFound),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -1014,21 +1025,21 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
ElevatedButton(
|
||||
onPressed: () =>
|
||||
_loadLibraryContent(_selectedLibraryKey!),
|
||||
child: const Text('Retry'),
|
||||
child: Text(t.common.retry),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (_items.isEmpty)
|
||||
const SliverFillRemaining(
|
||||
SliverFillRemaining(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.folder_open, size: 64, color: Colors.grey),
|
||||
SizedBox(height: 16),
|
||||
Text('This library is empty'),
|
||||
const Icon(Icons.folder_open, size: 64, color: Colors.grey),
|
||||
const SizedBox(height: 16),
|
||||
Text(t.libraries.thisLibraryIsEmpty),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -1086,7 +1097,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Loading library... (${_items.length} items loaded)',
|
||||
t.libraries.loadingLibraryWithCount(count: _items.length),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
@@ -1221,7 +1232,7 @@ class _FiltersBottomSheetState extends State<_FiltersBottomSheet> {
|
||||
);
|
||||
final client = clientProvider.client;
|
||||
if (client == null) {
|
||||
throw Exception('No client available');
|
||||
throw Exception(t.errors.noClientAvailable);
|
||||
}
|
||||
|
||||
final values = await client.getFilterValues(filter.key);
|
||||
@@ -1322,7 +1333,7 @@ class _FiltersBottomSheetState extends State<_FiltersBottomSheet> {
|
||||
_currentFilter!.filter,
|
||||
);
|
||||
return ListTile(
|
||||
title: const Text('All'),
|
||||
title: Text(t.libraries.all),
|
||||
selected: isSelected,
|
||||
onTap: () {
|
||||
setState(() {
|
||||
@@ -1380,9 +1391,9 @@ class _FiltersBottomSheetState extends State<_FiltersBottomSheet> {
|
||||
children: [
|
||||
const Icon(Icons.filter_list),
|
||||
const SizedBox(width: 12),
|
||||
const Text(
|
||||
'Filters',
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||
Text(
|
||||
t.libraries.filters,
|
||||
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const Spacer(),
|
||||
if (_tempSelectedFilters.isNotEmpty)
|
||||
@@ -1394,7 +1405,7 @@ class _FiltersBottomSheetState extends State<_FiltersBottomSheet> {
|
||||
_applyFilters();
|
||||
},
|
||||
icon: const Icon(Icons.clear_all),
|
||||
label: const Text('Clear All'),
|
||||
label: Text(t.libraries.clearAll),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
@@ -1524,10 +1535,10 @@ class _SortBottomSheetState extends State<_SortBottomSheet> {
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Expanded(
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Sort By',
|
||||
style: TextStyle(
|
||||
t.libraries.sortBy,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
@@ -1707,22 +1718,22 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(selectedItem.confirmationTitle ?? 'Confirm Action'),
|
||||
title: Text(selectedItem.confirmationTitle ?? t.dialog.confirmAction),
|
||||
content: Text(
|
||||
selectedItem.confirmationMessage ??
|
||||
'Are you sure you want to perform this action?',
|
||||
t.libraries.confirmActionMessage,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
child: Text(t.common.cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
style: selectedItem.isDestructive
|
||||
? TextButton.styleFrom(foregroundColor: Colors.red)
|
||||
: null,
|
||||
child: const Text('Confirm'),
|
||||
child: Text(t.common.confirm),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -1776,10 +1787,10 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
|
||||
children: [
|
||||
const Icon(Icons.edit),
|
||||
const SizedBox(width: 12),
|
||||
const Expanded(
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Manage Libraries',
|
||||
style: TextStyle(
|
||||
t.libraries.manageLibraries,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
@@ -1841,13 +1852,13 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
|
||||
: Icons.visibility,
|
||||
),
|
||||
onPressed: () => widget.onToggleVisibility(library),
|
||||
tooltip: isHidden ? 'Show library' : 'Hide library',
|
||||
tooltip: isHidden ? t.libraries.showLibrary : t.libraries.hideLibrary,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.more_vert),
|
||||
onPressed: () =>
|
||||
_showLibraryMenuBottomSheet(context, library),
|
||||
tooltip: 'Library options',
|
||||
tooltip: t.libraries.libraryOptions,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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,7 @@ 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 +146,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 +178,8 @@ 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),
|
||||
),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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';
|
||||
|
||||
@@ -40,7 +41,7 @@ class _LogsScreenState extends State<LogsScreen> {
|
||||
_logs = [];
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Logs cleared')),
|
||||
SnackBar(content: Text(t.messages.logsCleared)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -64,7 +65,7 @@ class _LogsScreenState extends State<LogsScreen> {
|
||||
}
|
||||
Clipboard.setData(ClipboardData(text: buffer.toString()));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Logs copied to clipboard')),
|
||||
SnackBar(content: Text(t.messages.logsCopied)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -108,30 +109,30 @@ class _LogsScreenState extends State<LogsScreen> {
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
CustomAppBar(
|
||||
title: const Text('Logs'),
|
||||
title: Text(t.screens.logs),
|
||||
pinned: true,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: _loadLogs,
|
||||
tooltip: 'Refresh',
|
||||
tooltip: t.common.refresh,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy),
|
||||
onPressed: _logs.isNotEmpty ? _copyAllLogs : null,
|
||||
tooltip: 'Copy All',
|
||||
tooltip: t.logs.copyLogs,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
onPressed: _logs.isNotEmpty ? _clearLogs : null,
|
||||
tooltip: 'Clear Logs',
|
||||
tooltip: t.logs.clearLogs,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_logs.isEmpty)
|
||||
const SliverFillRemaining(
|
||||
SliverFillRemaining(
|
||||
child: Center(
|
||||
child: Text('No logs available'),
|
||||
child: Text(t.messages.noLogsAvailable),
|
||||
),
|
||||
)
|
||||
else
|
||||
@@ -259,7 +260,7 @@ class _LogEntryCardState extends State<_LogEntryCard> {
|
||||
const SizedBox(height: 8),
|
||||
if (widget.log.error != null) ...[
|
||||
Text(
|
||||
'Error:',
|
||||
t.logs.error,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: widget.levelColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -285,7 +286,7 @@ class _LogEntryCardState extends State<_LogEntryCard> {
|
||||
if (widget.log.stackTrace != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Stack Trace:',
|
||||
t.logs.stackTrace,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: widget.levelColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../client/plex_client.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
import '../main.dart';
|
||||
@@ -93,22 +94,22 @@ class _MainScreenState extends State<MainScreen> with RouteAware {
|
||||
void _invalidateAllScreens() {
|
||||
appLogger.d('Invalidating all screen data due to profile switch');
|
||||
|
||||
// Refresh discover screen
|
||||
// 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 +128,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,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -12,6 +12,7 @@ import '../utils/video_player_navigation.dart';
|
||||
import '../utils/content_rating_formatter.dart';
|
||||
import '../utils/shuffle_play_helper.dart';
|
||||
import '../theme/theme_helper.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import 'season_detail_screen.dart';
|
||||
|
||||
class MediaDetailScreen extends StatefulWidget {
|
||||
@@ -182,7 +183,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;
|
||||
}
|
||||
@@ -196,7 +197,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
if (episodes.isEmpty) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('No episodes found in first season')),
|
||||
SnackBar(content: Text(t.messages.noEpisodesFound)),
|
||||
);
|
||||
}
|
||||
return;
|
||||
@@ -218,7 +219,7 @@ 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()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -583,7 +584,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
await handleShufflePlay(context, metadata);
|
||||
},
|
||||
icon: const Icon(Icons.shuffle),
|
||||
tooltip: 'Shuffle play',
|
||||
tooltip: t.tooltips.shufflePlay,
|
||||
iconSize: 20,
|
||||
style: IconButton.styleFrom(
|
||||
minimumSize: const Size(48, 48),
|
||||
@@ -603,8 +604,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
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
|
||||
@@ -613,13 +614,13 @@ 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),
|
||||
@@ -638,8 +639,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
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
|
||||
@@ -648,13 +649,13 @@ 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),
|
||||
@@ -669,7 +670,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,
|
||||
),
|
||||
@@ -705,7 +706,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),
|
||||
@@ -845,7 +846,7 @@ 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),
|
||||
),
|
||||
@@ -873,7 +874,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),
|
||||
),
|
||||
@@ -936,21 +940,21 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,7 @@ 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,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -6,10 +6,12 @@ import '../models/plex_metadata.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../widgets/media_card.dart';
|
||||
import '../widgets/desktop_app_bar.dart';
|
||||
import '../mixins/refreshable.dart';
|
||||
import '../mixins/item_updatable.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
|
||||
class SearchScreen extends StatefulWidget {
|
||||
const SearchScreen({super.key});
|
||||
@@ -107,7 +109,7 @@ class _SearchScreenState extends State<SearchScreen>
|
||||
});
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Search failed: $e')));
|
||||
).showSnackBar(SnackBar(content: Text(t.errors.searchFailed(error: e))));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,6 +122,19 @@ class _SearchScreenState extends State<SearchScreen>
|
||||
}
|
||||
}
|
||||
|
||||
// Public method to fully reload all content (for profile switches)
|
||||
void fullRefresh() {
|
||||
appLogger.d('SearchScreen.fullRefresh() called - clearing search and reloading');
|
||||
// Clear search results and search text for new profile
|
||||
_searchController.clear();
|
||||
setState(() {
|
||||
_searchResults.clear();
|
||||
_isSearching = false;
|
||||
_hasSearched = false;
|
||||
_lastSearchedQuery = '';
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) {
|
||||
final index = _searchResults.indexWhere(
|
||||
@@ -136,13 +151,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)
|
||||
@@ -198,14 +213,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),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -10,6 +10,7 @@ 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;
|
||||
@@ -100,7 +101,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,
|
||||
),
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../services/plex_auth_service.dart';
|
||||
import '../services/storage_service.dart';
|
||||
import '../services/server_connection_service.dart';
|
||||
@@ -27,6 +30,7 @@ class _ServerSelectionScreenState extends State<ServerSelectionScreen> {
|
||||
bool _isLoading = true;
|
||||
String? _errorMessage;
|
||||
String? _currentServerUrl;
|
||||
List<Map<String, dynamic>>? _debugServerData;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -52,12 +56,58 @@ class _ServerSelectionScreenState extends State<ServerSelectionScreen> {
|
||||
setState(() {
|
||||
_servers = servers;
|
||||
_isLoading = false;
|
||||
_debugServerData = null; // Clear any previous debug data
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_errorMessage = 'Failed to load servers: $e';
|
||||
_errorMessage = _getErrorMessage(e);
|
||||
_isLoading = false;
|
||||
// Store debug data if it's a parsing exception
|
||||
if (e is ServerParsingException) {
|
||||
_debugServerData = e.invalidServerData;
|
||||
} else {
|
||||
_debugServerData = null;
|
||||
}
|
||||
});
|
||||
appLogger.e('Failed to load servers', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
String _getErrorMessage(dynamic error) {
|
||||
if (error is ServerParsingException) {
|
||||
return t.serverSelection.malformedServerData(count: error.invalidServerData.length);
|
||||
} else if (error is FormatException) {
|
||||
// Handle JSON parsing errors with more user-friendly messages
|
||||
if (error.message.contains('Invalid server data')) {
|
||||
return t.serverSelection.incompleteServerInfo;
|
||||
} else if (error.message.contains('Invalid connection data')) {
|
||||
return t.serverSelection.incompleteConnectionInfo;
|
||||
}
|
||||
return t.serverSelection.malformedServerInfo(message: error.message);
|
||||
} else if (error.toString().contains('SocketException') ||
|
||||
error.toString().contains('TimeoutException')) {
|
||||
return t.serverSelection.networkConnectionFailed;
|
||||
} else if (error.toString().contains('401') ||
|
||||
error.toString().contains('Unauthorized')) {
|
||||
return t.serverSelection.authenticationFailed;
|
||||
} else if (error.toString().contains('404') ||
|
||||
error.toString().contains('Not Found')) {
|
||||
return t.serverSelection.plexServiceUnavailable;
|
||||
}
|
||||
|
||||
return t.serverSelection.failedToLoadServers(error: error.toString());
|
||||
}
|
||||
|
||||
Future<void> _copyDebugDataToClipboard() async {
|
||||
if (_debugServerData == null) return;
|
||||
|
||||
final jsonString = const JsonEncoder.withIndent(' ').convert(_debugServerData);
|
||||
await Clipboard.setData(ClipboardData(text: jsonString));
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(t.serverSelection.serverDebugCopied)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,13 +117,13 @@ class _ServerSelectionScreenState extends State<ServerSelectionScreen> {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => const AlertDialog(
|
||||
builder: (context) => AlertDialog(
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('Connecting to server...'),
|
||||
Text(t.serverSelection.connectingToServer),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -157,7 +207,7 @@ class _ServerSelectionScreenState extends State<ServerSelectionScreen> {
|
||||
// Show error
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(result.error ?? 'Connection failed')),
|
||||
SnackBar(content: Text(result.error ?? t.errors.connectionFailedGeneric)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -166,7 +216,7 @@ class _ServerSelectionScreenState extends State<ServerSelectionScreen> {
|
||||
if (mounted) {
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to connect to server: $e')),
|
||||
SnackBar(content: Text(t.messages.errorLoading(error: e.toString()))),
|
||||
);
|
||||
}
|
||||
appLogger.e('Server selection failed', error: e);
|
||||
@@ -190,7 +240,7 @@ class _ServerSelectionScreenState extends State<ServerSelectionScreen> {
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
const CustomAppBar(title: Text('Select Server')),
|
||||
CustomAppBar(title: Text(t.screens.selectServer)),
|
||||
SliverFillRemaining(
|
||||
child: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
@@ -199,23 +249,46 @@ class _ServerSelectionScreenState extends State<ServerSelectionScreen> {
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
_errorMessage!,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Text(
|
||||
_errorMessage!,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _loadServers,
|
||||
child: const Text('Retry'),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
ElevatedButton(
|
||||
onPressed: _loadServers,
|
||||
child: Text(t.common.retry),
|
||||
),
|
||||
if (_debugServerData != null) ...[
|
||||
const SizedBox(width: 16),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _copyDebugDataToClipboard,
|
||||
icon: const Icon(Icons.copy),
|
||||
label: Text(t.serverSelection.copyDebugData),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (_debugServerData != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Debug data available for ${_debugServerData!.length} server(s)',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
)
|
||||
: _servers == null || _servers!.isEmpty
|
||||
? const Center(child: Text('No servers found'))
|
||||
? Center(child: Text(t.serverSelection.noServersFound))
|
||||
: ListView.builder(
|
||||
itemCount: _servers!.length,
|
||||
padding: const EdgeInsets.all(16),
|
||||
|
||||
@@ -9,6 +9,7 @@ import '../services/keyboard_shortcuts_service.dart';
|
||||
import '../services/update_service.dart';
|
||||
import '../widgets/desktop_app_bar.dart';
|
||||
import '../widgets/hotkey_recorder_widget.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import 'about_screen.dart';
|
||||
import 'logs_screen.dart';
|
||||
import 'subtitle_styling_screen.dart';
|
||||
@@ -66,7 +67,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
const CustomAppBar(title: Text('Settings'), pinned: true),
|
||||
CustomAppBar(title: Text(t.settings.title), pinned: true),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
sliver: SliverList(
|
||||
@@ -75,6 +76,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
const SizedBox(height: 24),
|
||||
_buildVideoPlaybackSection(),
|
||||
const SizedBox(height: 24),
|
||||
_buildShufflePlaySection(),
|
||||
const SizedBox(height: 24),
|
||||
_buildKeyboardShortcutsSection(),
|
||||
const SizedBox(height: 24),
|
||||
_buildAdvancedSection(),
|
||||
@@ -101,7 +104,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'Appearance',
|
||||
t.settings.appearance,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
@@ -111,18 +114,27 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
builder: (context, themeProvider, child) {
|
||||
return ListTile(
|
||||
leading: Icon(themeProvider.themeModeIcon),
|
||||
title: const Text('Theme'),
|
||||
title: Text(t.settings.theme),
|
||||
subtitle: Text(themeProvider.themeModeDisplayName),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _showThemeDialog(themeProvider),
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.language),
|
||||
title: Text(t.settings.language),
|
||||
subtitle: Text(
|
||||
_getLanguageDisplayName(LocaleSettings.currentLocale),
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _showLanguageDialog(),
|
||||
),
|
||||
Consumer<SettingsProvider>(
|
||||
builder: (context, settingsProvider, child) {
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.grid_view),
|
||||
title: const Text('Library Density'),
|
||||
title: Text(t.settings.libraryDensity),
|
||||
subtitle: Text(settingsProvider.libraryDensityDisplayName),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _showLibraryDensityDialog(),
|
||||
@@ -133,8 +145,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
builder: (context, settingsProvider, child) {
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.view_list),
|
||||
title: const Text('View Mode'),
|
||||
subtitle: Text(settingsProvider.viewMode == settings.ViewMode.grid ? 'Grid' : 'List'),
|
||||
title: Text(t.settings.viewMode),
|
||||
subtitle: Text(
|
||||
settingsProvider.viewMode == settings.ViewMode.grid
|
||||
? t.settings.gridView
|
||||
: t.settings.listView,
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _showViewModeDialog(),
|
||||
);
|
||||
@@ -144,10 +160,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
builder: (context, settingsProvider, child) {
|
||||
return SwitchListTile(
|
||||
secondary: const Icon(Icons.image),
|
||||
title: const Text('Use Season Posters'),
|
||||
subtitle: const Text(
|
||||
'Show season poster instead of series poster for episodes',
|
||||
),
|
||||
title: Text(t.settings.useSeasonPosters),
|
||||
subtitle: Text(t.settings.useSeasonPostersDescription),
|
||||
value: settingsProvider.useSeasonPoster,
|
||||
onChanged: (value) async {
|
||||
await settingsProvider.setUseSeasonPoster(value);
|
||||
@@ -159,10 +173,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
builder: (context, settingsProvider, child) {
|
||||
return SwitchListTile(
|
||||
secondary: const Icon(Icons.featured_play_list),
|
||||
title: const Text('Show Hero Section'),
|
||||
subtitle: const Text(
|
||||
'Display featured content carousel on home screen',
|
||||
),
|
||||
title: Text(t.settings.showHeroSection),
|
||||
subtitle: Text(t.settings.showHeroSectionDescription),
|
||||
value: settingsProvider.showHeroSection,
|
||||
onChanged: (value) async {
|
||||
await settingsProvider.setShowHeroSection(value);
|
||||
@@ -183,7 +195,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'Video Playback',
|
||||
t.settings.videoPlayback,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
@@ -191,8 +203,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
SwitchListTile(
|
||||
secondary: const Icon(Icons.hardware),
|
||||
title: const Text('Hardware Decoding'),
|
||||
subtitle: const Text('Use hardware acceleration when available'),
|
||||
title: Text(t.settings.hardwareDecoding),
|
||||
subtitle: Text(t.settings.hardwareDecodingDescription),
|
||||
value: _enableHardwareDecoding,
|
||||
onChanged: (value) async {
|
||||
setState(() {
|
||||
@@ -203,15 +215,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.memory),
|
||||
title: const Text('Buffer Size'),
|
||||
subtitle: Text('${_bufferSize}MB'),
|
||||
title: Text(t.settings.bufferSize),
|
||||
subtitle: Text(
|
||||
t.settings.bufferSizeMB(size: _bufferSize.toString()),
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _showBufferSizeDialog(),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.subtitles),
|
||||
title: const Text('Subtitle Styling'),
|
||||
subtitle: const Text('Customize subtitle appearance'),
|
||||
title: Text(t.settings.subtitleStyling),
|
||||
subtitle: Text(t.settings.subtitleStylingDescription),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
@@ -224,22 +238,28 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.replay_10),
|
||||
title: const Text('Small Skip Duration'),
|
||||
subtitle: Text('$_seekTimeSmall seconds'),
|
||||
title: Text(t.settings.smallSkipDuration),
|
||||
subtitle: Text(
|
||||
t.settings.secondsUnit(seconds: _seekTimeSmall.toString()),
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _showSeekTimeSmallDialog(),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.replay_30),
|
||||
title: const Text('Large Skip Duration'),
|
||||
subtitle: Text('$_seekTimeLarge seconds'),
|
||||
title: Text(t.settings.largeSkipDuration),
|
||||
subtitle: Text(
|
||||
t.settings.secondsUnit(seconds: _seekTimeLarge.toString()),
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _showSeekTimeLargeDialog(),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.bedtime),
|
||||
title: const Text('Default Sleep Timer'),
|
||||
subtitle: Text('$_sleepTimerDuration minutes'),
|
||||
title: Text(t.settings.defaultSleepTimer),
|
||||
subtitle: Text(
|
||||
t.settings.minutesUnit(minutes: _sleepTimerDuration.toString()),
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _showSleepTimerDurationDialog(),
|
||||
),
|
||||
@@ -248,6 +268,64 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildShufflePlaySection() {
|
||||
return Card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
t.settings.shufflePlay,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
Consumer<SettingsProvider>(
|
||||
builder: (context, settingsProvider, child) {
|
||||
return SwitchListTile(
|
||||
secondary: const Icon(Icons.visibility_off),
|
||||
title: Text(t.settings.unwatchedOnly),
|
||||
subtitle: Text(t.settings.unwatchedOnlyDescription),
|
||||
value: settingsProvider.shuffleUnwatchedOnly,
|
||||
onChanged: (value) async {
|
||||
await settingsProvider.setShuffleUnwatchedOnly(value);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
Consumer<SettingsProvider>(
|
||||
builder: (context, settingsProvider, child) {
|
||||
return SwitchListTile(
|
||||
secondary: const Icon(Icons.shuffle),
|
||||
title: Text(t.settings.shuffleOrderNavigation),
|
||||
subtitle: Text(t.settings.shuffleOrderNavigationDescription),
|
||||
value: settingsProvider.shuffleOrderNavigation,
|
||||
onChanged: (value) async {
|
||||
await settingsProvider.setShuffleOrderNavigation(value);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
Consumer<SettingsProvider>(
|
||||
builder: (context, settingsProvider, child) {
|
||||
return SwitchListTile(
|
||||
secondary: const Icon(Icons.loop),
|
||||
title: Text(t.settings.loopShuffleQueue),
|
||||
subtitle: Text(t.settings.loopShuffleQueueDescription),
|
||||
value: settingsProvider.shuffleLoopQueue,
|
||||
onChanged: (value) async {
|
||||
await settingsProvider.setShuffleLoopQueue(value);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildKeyboardShortcutsSection() {
|
||||
return Card(
|
||||
child: Column(
|
||||
@@ -256,7 +334,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'Keyboard Shortcuts',
|
||||
t.settings.keyboardShortcuts,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
@@ -264,8 +342,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.keyboard),
|
||||
title: const Text('Video Player Controls'),
|
||||
subtitle: const Text('Customize keyboard shortcuts'),
|
||||
title: Text(t.settings.videoPlayerControls),
|
||||
subtitle: Text(t.settings.keyboardShortcutsDescription),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _showKeyboardShortcutsDialog(),
|
||||
),
|
||||
@@ -282,7 +360,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'Advanced',
|
||||
t.settings.advanced,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
@@ -290,8 +368,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
SwitchListTile(
|
||||
secondary: const Icon(Icons.bug_report),
|
||||
title: const Text('Debug Logging'),
|
||||
subtitle: const Text('Enable detailed logging for troubleshooting'),
|
||||
title: Text(t.settings.debugLogging),
|
||||
subtitle: Text(t.settings.debugLoggingDescription),
|
||||
value: _enableDebugLogging,
|
||||
onChanged: (value) async {
|
||||
setState(() {
|
||||
@@ -302,8 +380,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.article),
|
||||
title: const Text('View Logs'),
|
||||
subtitle: const Text('View application logs'),
|
||||
title: Text(t.settings.viewLogs),
|
||||
subtitle: Text(t.settings.viewLogsDescription),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
@@ -314,15 +392,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.cleaning_services),
|
||||
title: const Text('Clear Cache'),
|
||||
subtitle: const Text('Free up storage space'),
|
||||
title: Text(t.settings.clearCache),
|
||||
subtitle: Text(t.settings.clearCacheDescription),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _showClearCacheDialog(),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.restore),
|
||||
title: const Text('Reset Settings'),
|
||||
subtitle: const Text('Reset all settings to defaults'),
|
||||
title: Text(t.settings.resetSettings),
|
||||
subtitle: Text(t.settings.resetSettingsDescription),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _showResetSettingsDialog(),
|
||||
),
|
||||
@@ -341,7 +419,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'Updates',
|
||||
t.settings.updates,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
@@ -352,10 +430,18 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
hasUpdate ? Icons.system_update : Icons.check_circle,
|
||||
color: hasUpdate ? Colors.orange : null,
|
||||
),
|
||||
title: Text(hasUpdate ? 'Update Available' : 'Check for Updates'),
|
||||
title: Text(
|
||||
hasUpdate
|
||||
? t.settings.updateAvailable
|
||||
: t.settings.checkForUpdates,
|
||||
),
|
||||
subtitle: hasUpdate
|
||||
? Text('Version ${_updateInfo!['latestVersion']} is available')
|
||||
: const Text('Check for the latest version on GitHub'),
|
||||
? Text(
|
||||
t.update.versionAvailable(
|
||||
version: _updateInfo!['latestVersion'],
|
||||
),
|
||||
)
|
||||
: Text(t.update.checkFailed),
|
||||
trailing: _isCheckingForUpdate
|
||||
? const SizedBox(
|
||||
width: 24,
|
||||
@@ -382,8 +468,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
return Card(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.info),
|
||||
title: const Text('About'),
|
||||
subtitle: const Text('App information and licenses'),
|
||||
title: Text(t.settings.about),
|
||||
subtitle: Text(t.settings.aboutDescription),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
@@ -400,7 +486,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Theme'),
|
||||
title: Text(t.settings.theme),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@@ -410,8 +496,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
? Icons.radio_button_checked
|
||||
: Icons.radio_button_unchecked,
|
||||
),
|
||||
title: const Text('System'),
|
||||
subtitle: const Text('Follow system settings'),
|
||||
title: Text(t.settings.systemTheme),
|
||||
subtitle: Text(t.settings.systemThemeDescription),
|
||||
onTap: () {
|
||||
themeProvider.setThemeMode(settings.ThemeMode.system);
|
||||
Navigator.pop(context);
|
||||
@@ -423,7 +509,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
? Icons.radio_button_checked
|
||||
: Icons.radio_button_unchecked,
|
||||
),
|
||||
title: const Text('Light'),
|
||||
title: Text(t.settings.lightTheme),
|
||||
onTap: () {
|
||||
themeProvider.setThemeMode(settings.ThemeMode.light);
|
||||
Navigator.pop(context);
|
||||
@@ -435,7 +521,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
? Icons.radio_button_checked
|
||||
: Icons.radio_button_unchecked,
|
||||
),
|
||||
title: const Text('Dark'),
|
||||
title: Text(t.settings.darkTheme),
|
||||
onTap: () {
|
||||
themeProvider.setThemeMode(settings.ThemeMode.dark);
|
||||
Navigator.pop(context);
|
||||
@@ -446,7 +532,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
child: Text(t.common.cancel),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -461,7 +547,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Buffer Size'),
|
||||
title: Text(t.settings.bufferSize),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: options.map((size) {
|
||||
@@ -485,7 +571,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
child: Text(t.common.cancel),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -503,24 +589,28 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setDialogState) {
|
||||
return AlertDialog(
|
||||
title: const Text('Small Skip Duration'),
|
||||
title: Text(t.settings.smallSkipDuration),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Seconds',
|
||||
hintText: 'Enter duration (1-120)',
|
||||
labelText: t.settings.secondsLabel,
|
||||
hintText: t.settings.durationHint(min: 1, max: 120),
|
||||
errorText: errorText,
|
||||
suffixText: 's',
|
||||
suffixText: t.settings.secondsShort,
|
||||
),
|
||||
autofocus: true,
|
||||
onChanged: (value) {
|
||||
final parsed = int.tryParse(value);
|
||||
setDialogState(() {
|
||||
if (parsed == null) {
|
||||
errorText = 'Please enter a valid number';
|
||||
errorText = t.settings.validationErrorEnterNumber;
|
||||
} else if (parsed < 1 || parsed > 120) {
|
||||
errorText = 'Duration must be between 1 and 120 seconds';
|
||||
errorText = t.settings.validationErrorDuration(
|
||||
min: 1,
|
||||
max: 120,
|
||||
unit: t.settings.secondsLabel.toLowerCase(),
|
||||
);
|
||||
} else {
|
||||
errorText = null;
|
||||
}
|
||||
@@ -530,7 +620,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: const Text('Cancel'),
|
||||
child: Text(t.common.cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
@@ -547,7 +637,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
}
|
||||
}
|
||||
},
|
||||
child: const Text('Save'),
|
||||
child: Text(t.common.save),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -567,24 +657,28 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setDialogState) {
|
||||
return AlertDialog(
|
||||
title: const Text('Large Skip Duration'),
|
||||
title: Text(t.settings.largeSkipDuration),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Seconds',
|
||||
hintText: 'Enter duration (1-120)',
|
||||
labelText: t.settings.secondsLabel,
|
||||
hintText: t.settings.durationHint(min: 1, max: 120),
|
||||
errorText: errorText,
|
||||
suffixText: 's',
|
||||
suffixText: t.settings.secondsShort,
|
||||
),
|
||||
autofocus: true,
|
||||
onChanged: (value) {
|
||||
final parsed = int.tryParse(value);
|
||||
setDialogState(() {
|
||||
if (parsed == null) {
|
||||
errorText = 'Please enter a valid number';
|
||||
errorText = t.settings.validationErrorEnterNumber;
|
||||
} else if (parsed < 1 || parsed > 120) {
|
||||
errorText = 'Duration must be between 1 and 120 seconds';
|
||||
errorText = t.settings.validationErrorDuration(
|
||||
min: 1,
|
||||
max: 120,
|
||||
unit: t.settings.secondsLabel.toLowerCase(),
|
||||
);
|
||||
} else {
|
||||
errorText = null;
|
||||
}
|
||||
@@ -594,7 +688,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: const Text('Cancel'),
|
||||
child: Text(t.common.cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
@@ -611,7 +705,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
}
|
||||
}
|
||||
},
|
||||
child: const Text('Save'),
|
||||
child: Text(t.common.save),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -633,24 +727,28 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setDialogState) {
|
||||
return AlertDialog(
|
||||
title: const Text('Default Sleep Timer'),
|
||||
title: Text(t.settings.defaultSleepTimer),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Minutes',
|
||||
hintText: 'Enter duration (5-180)',
|
||||
labelText: t.settings.minutesLabel,
|
||||
hintText: t.settings.durationHint(min: 5, max: 180),
|
||||
errorText: errorText,
|
||||
suffixText: 'min',
|
||||
suffixText: t.settings.minutesShort,
|
||||
),
|
||||
autofocus: true,
|
||||
onChanged: (value) {
|
||||
final parsed = int.tryParse(value);
|
||||
setDialogState(() {
|
||||
if (parsed == null) {
|
||||
errorText = 'Please enter a valid number';
|
||||
errorText = t.settings.validationErrorEnterNumber;
|
||||
} else if (parsed < 5 || parsed > 180) {
|
||||
errorText = 'Duration must be between 5 and 180 minutes';
|
||||
errorText = t.settings.validationErrorDuration(
|
||||
min: 5,
|
||||
max: 180,
|
||||
unit: t.settings.minutesLabel.toLowerCase(),
|
||||
);
|
||||
} else {
|
||||
errorText = null;
|
||||
}
|
||||
@@ -660,7 +758,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: const Text('Cancel'),
|
||||
child: Text(t.common.cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
@@ -675,7 +773,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
}
|
||||
}
|
||||
},
|
||||
child: const Text('Save'),
|
||||
child: Text(t.common.save),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -700,14 +798,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Clear Cache'),
|
||||
content: const Text(
|
||||
'This will clear all cached images and data. The app may take longer to load content after clearing the cache.',
|
||||
),
|
||||
title: Text(t.settings.clearCache),
|
||||
content: Text(t.settings.clearCacheDescription),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
child: Text(t.common.cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
@@ -717,11 +813,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
if (mounted) {
|
||||
navigator.pop();
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('Cache cleared successfully')),
|
||||
SnackBar(content: Text(t.settings.clearCacheSuccess)),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text('Clear'),
|
||||
child: Text(t.common.clear),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -734,14 +830,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Reset Settings'),
|
||||
content: const Text(
|
||||
'This will reset all settings to their default values. This action cannot be undone.',
|
||||
),
|
||||
title: Text(t.settings.resetSettings),
|
||||
content: Text(t.settings.resetSettingsDescription),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
child: Text(t.common.cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
@@ -752,15 +846,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
if (mounted) {
|
||||
navigator.pop();
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Settings reset successfully'),
|
||||
),
|
||||
SnackBar(content: Text(t.settings.resetSettingsSuccess)),
|
||||
);
|
||||
// Reload settings
|
||||
_loadSettings();
|
||||
}
|
||||
},
|
||||
child: const Text('Reset'),
|
||||
child: Text(t.common.reset),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -768,6 +860,76 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
String _getLanguageDisplayName(AppLocale locale) {
|
||||
switch (locale) {
|
||||
case AppLocale.en:
|
||||
return 'English';
|
||||
case AppLocale.sv:
|
||||
return 'Svenska';
|
||||
}
|
||||
}
|
||||
|
||||
void _showLanguageDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(t.settings.language),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: AppLocale.values.map((locale) {
|
||||
final isSelected = LocaleSettings.currentLocale == locale;
|
||||
return ListTile(
|
||||
title: Text(_getLanguageDisplayName(locale)),
|
||||
leading: Icon(
|
||||
isSelected
|
||||
? Icons.radio_button_checked
|
||||
: Icons.radio_button_unchecked,
|
||||
color: isSelected ? Theme.of(context).colorScheme.primary : null,
|
||||
),
|
||||
tileColor: isSelected
|
||||
? Theme.of(context).colorScheme.primaryContainer.withValues(alpha: 0.3)
|
||||
: null,
|
||||
onTap: () async {
|
||||
// Save the locale to settings
|
||||
await _settingsService.setAppLocale(locale);
|
||||
|
||||
// Set the locale immediately
|
||||
LocaleSettings.setLocale(locale);
|
||||
|
||||
// Close dialog
|
||||
if (context.mounted) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
// Trigger app-wide rebuild by restarting the app
|
||||
if (context.mounted) {
|
||||
_restartApp();
|
||||
}
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(t.common.cancel),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _restartApp() {
|
||||
// Navigate to the root and remove all previous routes
|
||||
Navigator.pushNamedAndRemoveUntil(
|
||||
context,
|
||||
'/',
|
||||
(route) => false,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _checkForUpdates() async {
|
||||
setState(() {
|
||||
_isCheckingForUpdate = true;
|
||||
@@ -785,8 +947,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
if (updateInfo == null || updateInfo['hasUpdate'] != true) {
|
||||
// Show "no updates" message
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('You are on the latest version'),
|
||||
SnackBar(
|
||||
content: Text(t.update.latestVersion),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
@@ -799,8 +961,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
});
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Failed to check for updates'),
|
||||
SnackBar(
|
||||
content: Text(t.update.checkFailed),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
@@ -815,18 +977,22 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Update Available'),
|
||||
title: Text(t.settings.updateAvailable),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Version ${_updateInfo!['latestVersion']} is available',
|
||||
t.update.versionAvailable(
|
||||
version: _updateInfo!['latestVersion'],
|
||||
),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Current: ${_updateInfo!['currentVersion']}',
|
||||
t.update.currentVersion(
|
||||
version: _updateInfo!['currentVersion'],
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
@@ -834,7 +1000,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Close'),
|
||||
child: Text(t.common.close),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () async {
|
||||
@@ -844,7 +1010,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
}
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
},
|
||||
child: const Text('View Release'),
|
||||
child: Text(t.update.viewRelease),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -860,7 +1026,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
return Consumer<SettingsProvider>(
|
||||
builder: (context, provider, child) {
|
||||
return AlertDialog(
|
||||
title: const Text('Library Density'),
|
||||
title: Text(t.settings.libraryDensity),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@@ -870,8 +1036,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
? Icons.radio_button_checked
|
||||
: Icons.radio_button_unchecked,
|
||||
),
|
||||
title: const Text('Compact'),
|
||||
subtitle: const Text('Smaller cards, more items visible'),
|
||||
title: Text(t.settings.compact),
|
||||
subtitle: Text(t.settings.compactDescription),
|
||||
onTap: () async {
|
||||
await settingsProvider.setLibraryDensity(
|
||||
settings.LibraryDensity.compact,
|
||||
@@ -885,8 +1051,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
? Icons.radio_button_checked
|
||||
: Icons.radio_button_unchecked,
|
||||
),
|
||||
title: const Text('Normal'),
|
||||
subtitle: const Text('Default size'),
|
||||
title: Text(t.settings.normal),
|
||||
subtitle: Text(t.settings.normalDescription),
|
||||
onTap: () async {
|
||||
await settingsProvider.setLibraryDensity(
|
||||
settings.LibraryDensity.normal,
|
||||
@@ -901,8 +1067,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
? Icons.radio_button_checked
|
||||
: Icons.radio_button_unchecked,
|
||||
),
|
||||
title: const Text('Comfortable'),
|
||||
subtitle: const Text('Larger cards, fewer items visible'),
|
||||
title: Text(t.settings.comfortable),
|
||||
subtitle: Text(t.settings.comfortableDescription),
|
||||
onTap: () async {
|
||||
await settingsProvider.setLibraryDensity(
|
||||
settings.LibraryDensity.comfortable,
|
||||
@@ -915,7 +1081,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
child: Text(t.common.cancel),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -933,7 +1099,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
return Consumer<SettingsProvider>(
|
||||
builder: (context, provider, child) {
|
||||
return AlertDialog(
|
||||
title: const Text('View Mode'),
|
||||
title: Text(t.settings.viewMode),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@@ -943,8 +1109,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
? Icons.radio_button_checked
|
||||
: Icons.radio_button_unchecked,
|
||||
),
|
||||
title: const Text('Grid'),
|
||||
subtitle: const Text('Display items in a grid layout'),
|
||||
title: Text(t.settings.gridView),
|
||||
subtitle: Text(t.settings.gridViewDescription),
|
||||
onTap: () async {
|
||||
await settingsProvider.setViewMode(
|
||||
settings.ViewMode.grid,
|
||||
@@ -958,8 +1124,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
? Icons.radio_button_checked
|
||||
: Icons.radio_button_unchecked,
|
||||
),
|
||||
title: const Text('List'),
|
||||
subtitle: const Text('Display items in a list layout'),
|
||||
title: Text(t.settings.listView),
|
||||
subtitle: Text(t.settings.listViewDescription),
|
||||
onTap: () async {
|
||||
await settingsProvider.setViewMode(
|
||||
settings.ViewMode.list,
|
||||
@@ -972,7 +1138,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
child: Text(t.common.cancel),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -1021,7 +1187,7 @@ class _KeyboardShortcutsScreenState extends State<_KeyboardShortcutsScreen> {
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
CustomAppBar(
|
||||
title: const Text('Keyboard Shortcuts'),
|
||||
title: Text(t.settings.keyboardShortcuts),
|
||||
pinned: true,
|
||||
actions: [
|
||||
TextButton(
|
||||
@@ -1031,13 +1197,11 @@ class _KeyboardShortcutsScreenState extends State<_KeyboardShortcutsScreen> {
|
||||
await _loadHotkeys();
|
||||
if (mounted) {
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Shortcuts reset to defaults'),
|
||||
),
|
||||
SnackBar(content: Text(t.settings.shortcutsReset)),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text('Reset'),
|
||||
child: Text(t.common.reset),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -1103,7 +1267,11 @@ class _KeyboardShortcutsScreenState extends State<_KeyboardShortcutsScreen> {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Shortcut already assigned to ${widget.keyboardService.getActionDisplayName(existingAction)}',
|
||||
t.settings.shortcutAlreadyAssigned(
|
||||
action: widget.keyboardService.getActionDisplayName(
|
||||
existingAction,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -1124,7 +1292,11 @@ class _KeyboardShortcutsScreenState extends State<_KeyboardShortcutsScreen> {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Shortcut updated for ${widget.keyboardService.getActionDisplayName(action)}',
|
||||
t.settings.shortcutUpdated(
|
||||
action: widget.keyboardService.getActionDisplayName(
|
||||
action,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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';
|
||||
|
||||
@@ -51,13 +52,13 @@ class _SubtitleStylingScreenState extends State<SubtitleStylingScreen> {
|
||||
}
|
||||
|
||||
String _colorToHex(Color color) {
|
||||
return '#${color.red.toRadixString(16).padLeft(2, '0')}${color.green.toRadixString(16).padLeft(2, '0')}${color.blue.toRadixString(16).padLeft(2, '0')}'.toUpperCase();
|
||||
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(
|
||||
final Color selectedColor = await showColorPickerDialog(
|
||||
context,
|
||||
initialColor,
|
||||
title: Text(title),
|
||||
@@ -85,10 +86,8 @@ class _SubtitleStylingScreenState extends State<SubtitleStylingScreen> {
|
||||
),
|
||||
);
|
||||
|
||||
if (selectedColor != null) {
|
||||
final hexColor = _colorToHex(selectedColor);
|
||||
onColorSelected(hexColor);
|
||||
}
|
||||
final hexColor = _colorToHex(selectedColor);
|
||||
onColorSelected(hexColor);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -100,7 +99,7 @@ class _SubtitleStylingScreenState extends State<SubtitleStylingScreen> {
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
const CustomAppBar(title: Text('Subtitle Styling'), pinned: true),
|
||||
CustomAppBar(title: Text(t.screens.subtitleStyling), pinned: true),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
sliver: SliverList(
|
||||
@@ -123,7 +122,7 @@ class _SubtitleStylingScreenState extends State<SubtitleStylingScreen> {
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'Styling Options',
|
||||
t.subtitlingStyling.stylingOptions,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
@@ -136,7 +135,7 @@ class _SubtitleStylingScreenState extends State<SubtitleStylingScreen> {
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('Font Size'),
|
||||
Text(t.subtitlingStyling.fontSize),
|
||||
Text('$_fontSize'),
|
||||
],
|
||||
),
|
||||
@@ -179,11 +178,11 @@ class _SubtitleStylingScreenState extends State<SubtitleStylingScreen> {
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
title: const Text('Text Color'),
|
||||
title: Text(t.subtitlingStyling.textColor),
|
||||
subtitle: Text(_textColor),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
_showColorPicker('Text Color', _textColor, (color) {
|
||||
_showColorPicker(t.subtitlingStyling.textColor, _textColor, (color) {
|
||||
setState(() {
|
||||
_textColor = color;
|
||||
});
|
||||
@@ -201,7 +200,7 @@ class _SubtitleStylingScreenState extends State<SubtitleStylingScreen> {
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('Border Size'),
|
||||
Text(t.subtitlingStyling.borderSize),
|
||||
Text('$_borderSize'),
|
||||
],
|
||||
),
|
||||
@@ -244,11 +243,11 @@ class _SubtitleStylingScreenState extends State<SubtitleStylingScreen> {
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
title: const Text('Border Color'),
|
||||
title: Text(t.subtitlingStyling.borderColor),
|
||||
subtitle: Text(_borderColor),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
_showColorPicker('Border Color', _borderColor, (color) {
|
||||
_showColorPicker(t.subtitlingStyling.borderColor, _borderColor, (color) {
|
||||
setState(() {
|
||||
_borderColor = color;
|
||||
});
|
||||
@@ -266,7 +265,7 @@ class _SubtitleStylingScreenState extends State<SubtitleStylingScreen> {
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('Background Opacity'),
|
||||
Text(t.subtitlingStyling.backgroundOpacity),
|
||||
Text('$_backgroundOpacity%'),
|
||||
],
|
||||
),
|
||||
@@ -309,11 +308,11 @@ class _SubtitleStylingScreenState extends State<SubtitleStylingScreen> {
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
title: const Text('Background Color'),
|
||||
title: Text(t.subtitlingStyling.backgroundColor),
|
||||
subtitle: Text(_backgroundColor),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
_showColorPicker('Background Color', _backgroundColor, (color) {
|
||||
_showColorPicker(t.subtitlingStyling.backgroundColor, _backgroundColor, (color) {
|
||||
setState(() {
|
||||
_backgroundColor = color;
|
||||
});
|
||||
|
||||
@@ -1,24 +1,27 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:media_kit_video/media_kit_video.dart';
|
||||
import 'package:os_media_controls/os_media_controls.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:audio_session/audio_session.dart';
|
||||
|
||||
import '../models/plex_media_version.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../models/plex_user_profile.dart';
|
||||
import '../providers/plex_client_provider.dart';
|
||||
import '../providers/playback_state_provider.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
import '../widgets/video_controls/video_controls.dart';
|
||||
import '../utils/language_codes.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../providers/plex_client_provider.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../services/media_service_manager.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/language_codes.dart';
|
||||
import '../utils/orientation_helper.dart';
|
||||
import '../utils/video_player_navigation.dart';
|
||||
import '../utils/platform_detector.dart';
|
||||
import '../models/plex_media_version.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
import '../utils/video_player_navigation.dart';
|
||||
import '../widgets/video_controls/video_controls.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
|
||||
class VideoPlayerScreen extends StatefulWidget {
|
||||
final PlexMetadata metadata;
|
||||
@@ -56,6 +59,8 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
StreamSubscription<String>? _errorSubscription;
|
||||
StreamSubscription<bool>? _playingSubscription;
|
||||
StreamSubscription<bool>? _completedSubscription;
|
||||
StreamSubscription<Duration>? _positionSubscription;
|
||||
StreamSubscription<dynamic>? _mediaControlSubscription;
|
||||
bool _isReplacingWithVideo =
|
||||
false; // Flag to skip orientation restoration during video-to-video navigation
|
||||
|
||||
@@ -166,12 +171,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
final savedVolume = settingsService.getVolume();
|
||||
player!.setVolume(savedVolume);
|
||||
|
||||
// Initialize audio session for proper OS integration
|
||||
await _initializeAudioSession();
|
||||
|
||||
// Update media service manager with new player
|
||||
await _updateMediaService();
|
||||
|
||||
// Notify that player is ready
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
@@ -182,9 +181,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
// Get the video URL and start playback
|
||||
_startPlayback();
|
||||
|
||||
// Load available media versions
|
||||
_loadMediaVersions();
|
||||
|
||||
// Set fullscreen mode and orientation based on rotation lock setting
|
||||
if (mounted) {
|
||||
try {
|
||||
@@ -221,6 +217,14 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
// Listen to MPV errors
|
||||
_errorSubscription = player!.stream.error.listen(_onPlayerError);
|
||||
|
||||
// Listen to position updates for media controls
|
||||
_positionSubscription = player!.stream.position.listen((_) {
|
||||
_updateMediaControlsPosition();
|
||||
});
|
||||
|
||||
// Initialize OS media controls
|
||||
_initializeMediaControls();
|
||||
|
||||
// Start periodic progress updates
|
||||
_startProgressTracking();
|
||||
|
||||
@@ -236,82 +240,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _initializeAudioSession() async {
|
||||
try {
|
||||
final session = await AudioSession.instance;
|
||||
await session.configure(const AudioSessionConfiguration(
|
||||
avAudioSessionCategory: AVAudioSessionCategory.playback,
|
||||
avAudioSessionMode: AVAudioSessionMode.moviePlayback,
|
||||
androidAudioAttributes: AndroidAudioAttributes(
|
||||
contentType: AndroidAudioContentType.movie,
|
||||
usage: AndroidAudioUsage.media,
|
||||
),
|
||||
androidAudioFocusGainType: AndroidAudioFocusGainType.gain,
|
||||
));
|
||||
|
||||
// Handle interruptions (phone calls, other apps, etc.)
|
||||
session.interruptionEventStream.listen((event) {
|
||||
if (event.begin) {
|
||||
// Pause on interruption
|
||||
player?.pause();
|
||||
appLogger.i('Playback paused due to interruption');
|
||||
} else {
|
||||
// Resume after interruption if needed (user can manually play)
|
||||
switch (event.type) {
|
||||
case AudioInterruptionType.pause:
|
||||
case AudioInterruptionType.duck:
|
||||
// Don't auto-resume for these types
|
||||
break;
|
||||
case AudioInterruptionType.unknown:
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle audio becoming noisy (headphones unplugged)
|
||||
session.becomingNoisyEventStream.listen((_) {
|
||||
player?.pause();
|
||||
appLogger.i('Playback paused due to audio becoming noisy (headphones unplugged?)');
|
||||
});
|
||||
} catch (e) {
|
||||
appLogger.w('Failed to configure audio session', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _updateMediaService() async {
|
||||
if (!mounted) return;
|
||||
|
||||
try {
|
||||
final mediaService = MediaServiceManager.instance;
|
||||
|
||||
// Build thumbnail URL if available
|
||||
String? thumbnailUrl;
|
||||
final clientProvider = context.plexClient;
|
||||
final client = clientProvider.client;
|
||||
if (widget.metadata.thumb != null && client != null) {
|
||||
final baseUrl = client.config.baseUrl;
|
||||
final token = client.config.token;
|
||||
if (token != null) {
|
||||
thumbnailUrl = '$baseUrl${widget.metadata.thumb}?X-Plex-Token=$token';
|
||||
}
|
||||
}
|
||||
|
||||
// CRITICAL: Set mediaItem FIRST before updating player
|
||||
// Android requires mediaItem to exist before playbackState broadcasts
|
||||
mediaService.updateMediaItem(widget.metadata, thumbnailUrl);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
await mediaService.updatePlayer(
|
||||
player: player,
|
||||
onNext: _playNext,
|
||||
onPrevious: _playPrevious,
|
||||
);
|
||||
} catch (e) {
|
||||
appLogger.w('Failed to update media service', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadAdjacentEpisodes() async {
|
||||
if (widget.metadata.type.toLowerCase() != 'episode') {
|
||||
return;
|
||||
@@ -323,16 +251,31 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
if (client == null) return;
|
||||
|
||||
final playbackState = context.read<PlaybackStateProvider>();
|
||||
final settingsProvider = context.read<SettingsProvider>();
|
||||
|
||||
PlexMetadata? next;
|
||||
PlexMetadata? previous;
|
||||
|
||||
// Check if shuffle mode is active
|
||||
if (playbackState.isShuffleActive) {
|
||||
// Get next episode from shuffle queue
|
||||
next = playbackState.getNextEpisode(widget.metadata.ratingKey);
|
||||
// No previous episode in shuffle mode
|
||||
previous = null;
|
||||
// Get settings
|
||||
final shuffleOrderNavigation = settingsProvider.shuffleOrderNavigation;
|
||||
final loopQueue = settingsProvider.shuffleLoopQueue;
|
||||
|
||||
if (shuffleOrderNavigation) {
|
||||
// Use shuffled order for next/previous
|
||||
next = playbackState.getNextEpisode(
|
||||
widget.metadata.ratingKey,
|
||||
loopQueue: loopQueue,
|
||||
);
|
||||
previous = playbackState.getPreviousEpisode(
|
||||
widget.metadata.ratingKey,
|
||||
);
|
||||
} else {
|
||||
// Use chronological order even in shuffle mode
|
||||
next = await client.findAdjacentEpisode(widget.metadata, 1);
|
||||
previous = await client.findAdjacentEpisode(widget.metadata, -1);
|
||||
}
|
||||
} else {
|
||||
// Use normal sequential episode loading
|
||||
next = await client.findAdjacentEpisode(widget.metadata, 1);
|
||||
@@ -344,12 +287,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
_nextEpisode = next;
|
||||
_previousEpisode = previous;
|
||||
});
|
||||
|
||||
// Update media service navigation controls
|
||||
MediaServiceManager.instance.updateNavigationActions(
|
||||
hasNext: _nextEpisode != null,
|
||||
hasPrevious: _previousEpisode != null,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
// Silently handle errors
|
||||
@@ -364,18 +301,22 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
throw Exception('No client available');
|
||||
}
|
||||
|
||||
// Get the direct file URL from the server using the selected media index
|
||||
final videoUrl = await client.getVideoUrl(
|
||||
// Get consolidated playback data (URL, media info, and versions) in a single API call
|
||||
final playbackData = await client.getVideoPlaybackData(
|
||||
widget.metadata.ratingKey,
|
||||
mediaIndex: widget.selectedMediaIndex,
|
||||
);
|
||||
|
||||
if (videoUrl != null) {
|
||||
// Fetch media info to check for external subtitle tracks
|
||||
final mediaInfo = await client.getMediaInfo(
|
||||
widget.metadata.ratingKey,
|
||||
mediaIndex: widget.selectedMediaIndex,
|
||||
);
|
||||
if (playbackData.hasValidVideoUrl) {
|
||||
final videoUrl = playbackData.videoUrl!;
|
||||
final mediaInfo = playbackData.mediaInfo;
|
||||
|
||||
// Update available versions from the playback data
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_availableVersions = playbackData.availableVersions;
|
||||
});
|
||||
}
|
||||
|
||||
// Build list of external subtitle tracks for media_kit
|
||||
final externalSubtitles = <SubtitleTrack>[];
|
||||
@@ -476,16 +417,12 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
// Start playback after seeking
|
||||
await player!.play();
|
||||
|
||||
// Force media service state update to trigger Android notification
|
||||
// This ensures the notification appears even if streams don't fire reliably
|
||||
MediaServiceManager.instance.forceStateUpdate();
|
||||
|
||||
// Wait for tracks to be loaded, then apply preferred tracks
|
||||
_waitForTracksAndApply();
|
||||
} else {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Could not find video file')),
|
||||
SnackBar(content: Text(t.messages.fileInfoNotAvailable)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -493,29 +430,11 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Error: $e')));
|
||||
).showSnackBar(SnackBar(content: Text(t.messages.errorLoading(error: e.toString()))));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load available media versions for this item
|
||||
Future<void> _loadMediaVersions() async {
|
||||
try {
|
||||
final clientProvider = context.plexClient;
|
||||
final client = clientProvider.client;
|
||||
if (client == null) return;
|
||||
|
||||
final versions = await client.getMediaVersions(widget.metadata.ratingKey);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_availableVersions = versions;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.e('Error loading media versions: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Cycle through BoxFit modes: contain → cover → fill → contain (for button)
|
||||
void _cycleBoxFitMode() {
|
||||
setState(() {
|
||||
@@ -554,9 +473,11 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
_completedSubscription?.cancel();
|
||||
_logSubscription?.cancel();
|
||||
_errorSubscription?.cancel();
|
||||
_positionSubscription?.cancel();
|
||||
_mediaControlSubscription?.cancel();
|
||||
|
||||
// Stop media service and clear OS controls
|
||||
MediaServiceManager.instance.stop();
|
||||
// Clear OS media controls completely
|
||||
OsMediaControls.clear();
|
||||
|
||||
// Send final stopped state
|
||||
_sendProgress('stopped');
|
||||
@@ -1150,6 +1071,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
void _onPlayingStateChanged(bool isPlaying) {
|
||||
// Send timeline update when playback state changes
|
||||
_sendProgress(isPlaying ? 'playing' : 'paused');
|
||||
|
||||
// Update OS media controls playback state
|
||||
_updateMediaControlsPlaybackState();
|
||||
}
|
||||
|
||||
void _sendProgress(String state) {
|
||||
@@ -1212,6 +1136,139 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
appLogger.e('[MPV ERROR] $error');
|
||||
}
|
||||
|
||||
// OS Media Controls Integration
|
||||
|
||||
void _initializeMediaControls() async {
|
||||
// Listen to media control events
|
||||
_mediaControlSubscription = OsMediaControls.controlEvents.listen((event) {
|
||||
if (event is PlayEvent) {
|
||||
player?.play();
|
||||
} else if (event is PauseEvent) {
|
||||
player?.pause();
|
||||
} else if (event is SeekEvent) {
|
||||
player?.seek(event.position);
|
||||
} else if (event is NextTrackEvent) {
|
||||
if (_nextEpisode != null) {
|
||||
_playNext();
|
||||
}
|
||||
} else if (event is PreviousTrackEvent) {
|
||||
if (_previousEpisode != null) {
|
||||
_playPrevious();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Enable/disable next/previous track controls based on content type
|
||||
final isEpisode = widget.metadata.type.toLowerCase() == 'episode';
|
||||
if (isEpisode) {
|
||||
// Enable next/previous track controls for episodes
|
||||
await OsMediaControls.enableControls([
|
||||
MediaControl.next,
|
||||
MediaControl.previous,
|
||||
]);
|
||||
} else {
|
||||
// Disable next/previous track controls for movies
|
||||
await OsMediaControls.disableControls([
|
||||
MediaControl.next,
|
||||
MediaControl.previous,
|
||||
]);
|
||||
}
|
||||
|
||||
// Set initial metadata
|
||||
await _updateMediaMetadata();
|
||||
}
|
||||
|
||||
Future<void> _updateMediaMetadata() async {
|
||||
if (!mounted) {
|
||||
appLogger.w('Cannot update media metadata: widget not mounted');
|
||||
return;
|
||||
}
|
||||
|
||||
final metadata = widget.metadata;
|
||||
final clientProvider = context.plexClient;
|
||||
final client = clientProvider.client;
|
||||
|
||||
// Get artwork URL
|
||||
String? artworkUrl;
|
||||
if (client == null) {
|
||||
appLogger.w(
|
||||
'Cannot get artwork URL for media controls: Plex client is null',
|
||||
);
|
||||
} else {
|
||||
final thumbUrl = metadata.type.toLowerCase() == 'episode'
|
||||
? metadata.grandparentThumb ?? metadata.thumb
|
||||
: metadata.thumb;
|
||||
|
||||
if (thumbUrl != null) {
|
||||
try {
|
||||
artworkUrl = client.getThumbnailUrl(thumbUrl);
|
||||
appLogger.d('Artwork URL for media controls: $artworkUrl');
|
||||
} catch (e) {
|
||||
appLogger.w('Failed to get artwork URL for media controls', error: e);
|
||||
}
|
||||
} else {
|
||||
appLogger.d('No thumbnail URL available for media controls');
|
||||
}
|
||||
}
|
||||
|
||||
// Build title/artist based on content type
|
||||
String title = metadata.title;
|
||||
String? artist;
|
||||
String? album;
|
||||
|
||||
if (metadata.type.toLowerCase() == 'episode') {
|
||||
title = metadata.title;
|
||||
artist = metadata.grandparentTitle; // Show name
|
||||
if (metadata.parentIndex != null) {
|
||||
album = 'Season ${metadata.parentIndex}';
|
||||
}
|
||||
}
|
||||
|
||||
await OsMediaControls.setMetadata(
|
||||
MediaMetadata(
|
||||
title: title,
|
||||
artist: artist,
|
||||
album: album,
|
||||
duration: metadata.duration != null
|
||||
? Duration(milliseconds: metadata.duration!)
|
||||
: null,
|
||||
artworkUrl: artworkUrl,
|
||||
),
|
||||
);
|
||||
|
||||
// Set initial playback state
|
||||
_updateMediaControlsPlaybackState();
|
||||
}
|
||||
|
||||
void _updateMediaControlsPlaybackState() {
|
||||
if (player == null) return;
|
||||
|
||||
OsMediaControls.setPlaybackState(
|
||||
MediaPlaybackState(
|
||||
state: player!.state.playing
|
||||
? PlaybackState.playing
|
||||
: PlaybackState.paused,
|
||||
position: player!.state.position,
|
||||
speed: player!.state.rate,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _updateMediaControlsPosition() {
|
||||
if (player == null) return;
|
||||
|
||||
// Only update if playing to avoid excessive updates
|
||||
if (player!.state.playing) {
|
||||
OsMediaControls.setPlaybackState(
|
||||
MediaPlaybackState(
|
||||
state: PlaybackState.playing,
|
||||
position: player!.state.position,
|
||||
speed: player!.state.rate,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _playNext() async {
|
||||
if (_nextEpisode == null || _isLoadingNext) return;
|
||||
|
||||
@@ -1424,7 +1481,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
vertical: 16,
|
||||
),
|
||||
),
|
||||
child: const Text('Cancel'),
|
||||
child: Text(t.dialog.cancel),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
FilledButton(
|
||||
@@ -1437,7 +1494,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
vertical: 16,
|
||||
),
|
||||
),
|
||||
child: const Text('Play Now'),
|
||||
child: Text(t.dialog.playNow),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
|
||||
/// AudioHandler that bridges media_kit Player with OS media controls
|
||||
class MediaKitAudioHandler extends BaseAudioHandler with SeekHandler {
|
||||
Player? _player;
|
||||
VoidCallback? _onNext;
|
||||
VoidCallback? _onPrevious;
|
||||
|
||||
StreamSubscription<bool>? _playingSubscription;
|
||||
StreamSubscription<Duration>? _positionSubscription;
|
||||
StreamSubscription<bool>? _completedSubscription;
|
||||
|
||||
MediaKitAudioHandler({
|
||||
required Player? player,
|
||||
VoidCallback? onNext,
|
||||
VoidCallback? onPrevious,
|
||||
}) : _player = player,
|
||||
_onNext = onNext,
|
||||
_onPrevious = onPrevious {
|
||||
if (_player != null) {
|
||||
_initializeListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void _initializeListeners() {
|
||||
if (_player == null) return;
|
||||
|
||||
_playingSubscription = _player!.stream.playing.listen((_) {
|
||||
_broadcastState();
|
||||
});
|
||||
|
||||
_positionSubscription = _player!.stream.position.listen((_) {
|
||||
_broadcastState();
|
||||
});
|
||||
|
||||
_completedSubscription = _player!.stream.completed.listen((completed) {
|
||||
if (completed) {
|
||||
_broadcastState();
|
||||
}
|
||||
});
|
||||
|
||||
_broadcastState();
|
||||
}
|
||||
|
||||
/// Update the player reference and callbacks (for video changes)
|
||||
Future<void> updatePlayer({
|
||||
required Player? player,
|
||||
VoidCallback? onNext,
|
||||
VoidCallback? onPrevious,
|
||||
}) async {
|
||||
await _cleanupSubscriptions();
|
||||
|
||||
// Update player and callbacks
|
||||
_player = player;
|
||||
_onNext = onNext;
|
||||
_onPrevious = onPrevious;
|
||||
|
||||
// Set up new subscriptions if player is not null
|
||||
if (_player != null) {
|
||||
_initializeListeners();
|
||||
} else {
|
||||
// No player, broadcast idle state
|
||||
_broadcastIdleState();
|
||||
}
|
||||
}
|
||||
|
||||
/// Clean up current subscriptions
|
||||
Future<void> _cleanupSubscriptions() async {
|
||||
await _playingSubscription?.cancel();
|
||||
await _positionSubscription?.cancel();
|
||||
await _completedSubscription?.cancel();
|
||||
_playingSubscription = null;
|
||||
_positionSubscription = null;
|
||||
_completedSubscription = null;
|
||||
}
|
||||
|
||||
void _broadcastState() {
|
||||
if (_player == null || mediaItem.value == null) {
|
||||
if (_player == null) {
|
||||
_broadcastIdleState();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final playing = _player!.state.playing;
|
||||
final position = _player!.state.position;
|
||||
final duration = _player!.state.duration;
|
||||
final rate = _player!.state.rate;
|
||||
|
||||
// Determine processing state
|
||||
AudioProcessingState processingState;
|
||||
if (_player!.state.completed) {
|
||||
processingState = AudioProcessingState.completed;
|
||||
} else if (duration.inMilliseconds > 0) {
|
||||
processingState = AudioProcessingState.ready;
|
||||
} else {
|
||||
processingState = AudioProcessingState.loading;
|
||||
}
|
||||
|
||||
// Build control list
|
||||
final controls = <MediaControl>[
|
||||
if (_onPrevious != null) MediaControl.skipToPrevious,
|
||||
playing ? MediaControl.pause : MediaControl.play,
|
||||
MediaControl.stop,
|
||||
if (_onNext != null) MediaControl.skipToNext,
|
||||
];
|
||||
|
||||
final compactIndices = _calculateCompactActionIndices(controls);
|
||||
|
||||
playbackState.add(PlaybackState(
|
||||
controls: controls,
|
||||
systemActions: const {
|
||||
MediaAction.seek,
|
||||
MediaAction.seekForward,
|
||||
MediaAction.seekBackward,
|
||||
},
|
||||
androidCompactActionIndices: compactIndices,
|
||||
playing: playing,
|
||||
updatePosition: position,
|
||||
speed: rate,
|
||||
processingState: processingState,
|
||||
));
|
||||
}
|
||||
|
||||
void _broadcastIdleState() {
|
||||
playbackState.add(PlaybackState(
|
||||
controls: [],
|
||||
systemActions: const {},
|
||||
playing: false,
|
||||
processingState: AudioProcessingState.idle,
|
||||
));
|
||||
}
|
||||
|
||||
/// Calculate compact action indices for Android notification
|
||||
/// Returns indices for the most important controls to show in compact view
|
||||
List<int> _calculateCompactActionIndices(List<MediaControl> controls) {
|
||||
final indices = <int>[];
|
||||
|
||||
// Find indices of key controls
|
||||
int? previousIndex;
|
||||
int? playPauseIndex;
|
||||
int? nextIndex;
|
||||
|
||||
for (int i = 0; i < controls.length; i++) {
|
||||
if (controls[i] == MediaControl.skipToPrevious) {
|
||||
previousIndex = i;
|
||||
} else if (controls[i] == MediaControl.play ||
|
||||
controls[i] == MediaControl.pause) {
|
||||
playPauseIndex = i;
|
||||
} else if (controls[i] == MediaControl.skipToNext) {
|
||||
nextIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
// Build compact indices: Previous (if exists), Play/Pause, Next (if exists)
|
||||
if (previousIndex != null) indices.add(previousIndex);
|
||||
if (playPauseIndex != null) indices.add(playPauseIndex);
|
||||
if (nextIndex != null) indices.add(nextIndex);
|
||||
|
||||
// Ensure we have at least the play/pause button
|
||||
if (indices.isEmpty && playPauseIndex != null) {
|
||||
indices.add(playPauseIndex);
|
||||
}
|
||||
|
||||
return indices;
|
||||
}
|
||||
|
||||
/// Update the media item shown in OS controls
|
||||
void setMediaItemFromMetadata(PlexMetadata metadata, String? thumbnailUrl) {
|
||||
final title = metadata.type.toLowerCase() == 'episode'
|
||||
? metadata.title
|
||||
: metadata.title;
|
||||
|
||||
final artist = metadata.type.toLowerCase() == 'episode'
|
||||
? metadata.grandparentTitle ?? metadata.year?.toString()
|
||||
: metadata.year?.toString();
|
||||
|
||||
final album = metadata.type.toLowerCase() == 'episode'
|
||||
? 'S${metadata.parentIndex} · E${metadata.index} · ${metadata.parentTitle ?? ""}'
|
||||
: metadata.studio;
|
||||
|
||||
final duration = metadata.duration != null
|
||||
? Duration(milliseconds: metadata.duration!)
|
||||
: Duration.zero;
|
||||
|
||||
mediaItem.add(MediaItem(
|
||||
id: metadata.ratingKey,
|
||||
title: title,
|
||||
artist: artist,
|
||||
album: album,
|
||||
duration: duration,
|
||||
artUri: thumbnailUrl != null ? Uri.parse(thumbnailUrl) : null,
|
||||
extras: {
|
||||
'ratingKey': metadata.ratingKey,
|
||||
'type': metadata.type,
|
||||
},
|
||||
));
|
||||
|
||||
appLogger.i('Media item updated: $title${artist != null ? " - $artist" : ""}');
|
||||
}
|
||||
|
||||
/// Update whether next/previous actions are available
|
||||
void updateNavigationActions({bool? hasNext, bool? hasPrevious}) {
|
||||
_broadcastState();
|
||||
}
|
||||
|
||||
/// Force an immediate state update
|
||||
/// Use this after playback starts to ensure notification appears
|
||||
void forceStateUpdate() {
|
||||
_broadcastState();
|
||||
}
|
||||
|
||||
// BaseAudioHandler implementations
|
||||
@override
|
||||
Future<void> play() async {
|
||||
if (_player == null) return;
|
||||
await _player!.play();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> pause() async {
|
||||
if (_player == null) return;
|
||||
await _player!.pause();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> stop() async {
|
||||
if (_player != null) {
|
||||
await _player!.pause();
|
||||
}
|
||||
|
||||
playbackState.add(PlaybackState(
|
||||
controls: [],
|
||||
systemActions: const {},
|
||||
playing: false,
|
||||
processingState: AudioProcessingState.idle,
|
||||
));
|
||||
|
||||
await super.stop();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> seek(Duration position) async {
|
||||
if (_player == null) return;
|
||||
await _player!.seek(position);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> skipToNext() async {
|
||||
_onNext?.call();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> skipToPrevious() async {
|
||||
_onPrevious?.call();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> fastForward() async {
|
||||
if (_player == null) return;
|
||||
final newPosition = _player!.state.position + const Duration(seconds: 15);
|
||||
await _player!.seek(newPosition);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> rewind() async {
|
||||
if (_player == null) return;
|
||||
final newPosition = _player!.state.position - const Duration(seconds: 15);
|
||||
await _player!.seek(newPosition > Duration.zero ? newPosition : Duration.zero);
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
appLogger.d('Disposing MediaKitAudioHandler');
|
||||
await _cleanupSubscriptions();
|
||||
await stop();
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'media_kit_audio_handler.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
|
||||
/// Singleton manager for OS media controls integration
|
||||
/// Manages a single AudioHandler instance for the entire app lifecycle
|
||||
class MediaServiceManager {
|
||||
static MediaServiceManager? _instance;
|
||||
static MediaKitAudioHandler? _audioHandler;
|
||||
static bool _isInitialized = false;
|
||||
|
||||
MediaServiceManager._();
|
||||
|
||||
static MediaServiceManager get instance {
|
||||
_instance ??= MediaServiceManager._();
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
/// Initialize the audio service once at app startup
|
||||
Future<void> initialize() async {
|
||||
if (_isInitialized) {
|
||||
appLogger.w('MediaServiceManager already initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
appLogger.i('Initializing MediaServiceManager');
|
||||
|
||||
_audioHandler = await AudioService.init(
|
||||
builder: () => MediaKitAudioHandler(
|
||||
player: null, // Will be set when first video plays
|
||||
onNext: null,
|
||||
onPrevious: null,
|
||||
),
|
||||
config: const AudioServiceConfig(
|
||||
androidNotificationChannelId: 'com.plezy.app.channel.audio',
|
||||
androidNotificationChannelName: 'Plezy Playback',
|
||||
androidNotificationOngoing: false,
|
||||
androidStopForegroundOnPause: true,
|
||||
androidNotificationIcon: 'drawable/ic_stat_notification',
|
||||
),
|
||||
);
|
||||
|
||||
_isInitialized = true;
|
||||
appLogger.i('MediaServiceManager initialized successfully');
|
||||
} catch (e, stackTrace) {
|
||||
appLogger.e(
|
||||
'❌ Failed to initialize MediaServiceManager',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
// Non-fatal, app can continue without OS media controls
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the audio handler with a new player and callbacks
|
||||
Future<void> updatePlayer({
|
||||
required Player? player,
|
||||
VoidCallback? onNext,
|
||||
VoidCallback? onPrevious,
|
||||
}) async {
|
||||
if (!_isInitialized || _audioHandler == null) {
|
||||
appLogger.w('MediaServiceManager not initialized, cannot update player');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await _audioHandler!.updatePlayer(
|
||||
player: player,
|
||||
onNext: onNext,
|
||||
onPrevious: onPrevious,
|
||||
);
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to update player', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop playback and clear OS controls
|
||||
Future<void> stop() async {
|
||||
if (_audioHandler == null) return;
|
||||
|
||||
try {
|
||||
await _audioHandler!.stop();
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to stop audio service', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the media item shown in OS controls
|
||||
void updateMediaItem(dynamic metadata, String? thumbnailUrl) {
|
||||
if (_audioHandler == null) return;
|
||||
|
||||
try {
|
||||
_audioHandler!.setMediaItemFromMetadata(metadata, thumbnailUrl);
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to update media item', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Update navigation actions availability
|
||||
void updateNavigationActions({bool? hasNext, bool? hasPrevious}) {
|
||||
_audioHandler?.updateNavigationActions(
|
||||
hasNext: hasNext,
|
||||
hasPrevious: hasPrevious,
|
||||
);
|
||||
}
|
||||
|
||||
/// Force an immediate state update to trigger notification
|
||||
/// Call this after playback starts to ensure Android shows the notification
|
||||
void forceStateUpdate() {
|
||||
_audioHandler?.forceStateUpdate();
|
||||
}
|
||||
|
||||
/// Check if the service is initialized
|
||||
bool get isInitialized => _isInitialized;
|
||||
|
||||
/// Get the audio handler (for advanced usage)
|
||||
MediaKitAudioHandler? get audioHandler => _audioHandler;
|
||||
}
|
||||
@@ -143,10 +143,30 @@ class PlexAuthService {
|
||||
final List<dynamic> resources = response.data as List<dynamic>;
|
||||
|
||||
// Filter for server resources and map to PlexServer objects
|
||||
return resources
|
||||
.where((r) => r['provides'] == 'server')
|
||||
.map((r) => PlexServer.fromJson(r as Map<String, dynamic>))
|
||||
.toList();
|
||||
final servers = <PlexServer>[];
|
||||
final invalidServers = <Map<String, dynamic>>[];
|
||||
|
||||
for (final resource in resources.where((r) => r['provides'] == 'server')) {
|
||||
try {
|
||||
final server = PlexServer.fromJson(resource as Map<String, dynamic>);
|
||||
servers.add(server);
|
||||
} catch (e) {
|
||||
// Collect invalid servers for debugging
|
||||
invalidServers.add(resource as Map<String, dynamic>);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// If we have invalid servers but some valid ones, that's okay
|
||||
// If we have no valid servers but some invalid ones, throw with debug info
|
||||
if (servers.isEmpty && invalidServers.isNotEmpty) {
|
||||
throw ServerParsingException(
|
||||
'No valid servers found. All ${invalidServers.length} server(s) have malformed data.',
|
||||
invalidServers,
|
||||
);
|
||||
}
|
||||
|
||||
return servers;
|
||||
}
|
||||
|
||||
/// Get user information
|
||||
@@ -249,20 +269,35 @@ class PlexServer {
|
||||
});
|
||||
|
||||
factory PlexServer.fromJson(Map<String, dynamic> json) {
|
||||
// Validate required fields first
|
||||
if (!_isValidServerJson(json)) {
|
||||
throw FormatException('Invalid server data: missing required fields (name, clientIdentifier, accessToken, or connections)');
|
||||
}
|
||||
|
||||
final List<dynamic> connectionsJson = json['connections'] as List<dynamic>;
|
||||
final connections = <PlexConnection>[];
|
||||
|
||||
// Parse connections and generate HTTP fallbacks for HTTPS connections
|
||||
for (final c in connectionsJson) {
|
||||
final connection = PlexConnection.fromJson(c as Map<String, dynamic>);
|
||||
connections.add(connection);
|
||||
try {
|
||||
final connection = PlexConnection.fromJson(c as Map<String, dynamic>);
|
||||
connections.add(connection);
|
||||
|
||||
// Generate HTTP fallback for HTTPS connections
|
||||
if (connection.protocol == 'https') {
|
||||
connections.add(connection.toHttpFallback());
|
||||
// Generate HTTP fallback for HTTPS connections
|
||||
if (connection.protocol == 'https') {
|
||||
connections.add(connection.toHttpFallback());
|
||||
}
|
||||
} catch (e) {
|
||||
// Skip invalid connections rather than failing the entire server
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// If no valid connections were parsed, this server is unusable
|
||||
if (connections.isEmpty) {
|
||||
throw FormatException('Server has no valid connections');
|
||||
}
|
||||
|
||||
DateTime? lastSeenAt;
|
||||
if (json['lastSeenAt'] != null) {
|
||||
try {
|
||||
@@ -273,9 +308,9 @@ class PlexServer {
|
||||
}
|
||||
|
||||
return PlexServer(
|
||||
name: json['name'] as String,
|
||||
clientIdentifier: json['clientIdentifier'] as String,
|
||||
accessToken: json['accessToken'] as String,
|
||||
name: json['name'] as String, // Safe because validated above
|
||||
clientIdentifier: json['clientIdentifier'] as String, // Safe because validated above
|
||||
accessToken: json['accessToken'] as String, // Safe because validated above
|
||||
connections: connections,
|
||||
owned: json['owned'] as bool? ?? false,
|
||||
product: json['product'] as String?,
|
||||
@@ -285,6 +320,27 @@ class PlexServer {
|
||||
);
|
||||
}
|
||||
|
||||
/// Validates that server JSON contains all required fields with correct types
|
||||
static bool _isValidServerJson(Map<String, dynamic> json) {
|
||||
// Check for required string fields
|
||||
if (json['name'] is! String || (json['name'] as String).isEmpty) {
|
||||
return false;
|
||||
}
|
||||
if (json['clientIdentifier'] is! String || (json['clientIdentifier'] as String).isEmpty) {
|
||||
return false;
|
||||
}
|
||||
if (json['accessToken'] is! String || (json['accessToken'] as String).isEmpty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for connections array
|
||||
if (json['connections'] is! List || (json['connections'] as List).isEmpty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'name': name,
|
||||
@@ -516,17 +572,43 @@ class PlexConnection {
|
||||
});
|
||||
|
||||
factory PlexConnection.fromJson(Map<String, dynamic> json) {
|
||||
// Validate required fields
|
||||
if (!_isValidConnectionJson(json)) {
|
||||
throw FormatException('Invalid connection data: missing required fields (protocol, address, port, or uri)');
|
||||
}
|
||||
|
||||
return PlexConnection(
|
||||
protocol: json['protocol'] as String,
|
||||
address: json['address'] as String,
|
||||
port: json['port'] as int,
|
||||
uri: json['uri'] as String,
|
||||
protocol: json['protocol'] as String, // Safe because validated above
|
||||
address: json['address'] as String, // Safe because validated above
|
||||
port: json['port'] as int, // Safe because validated above
|
||||
uri: json['uri'] as String, // Safe because validated above
|
||||
local: json['local'] as bool? ?? false,
|
||||
relay: json['relay'] as bool? ?? false,
|
||||
ipv6: json['IPv6'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
/// Validates that connection JSON contains all required fields with correct types
|
||||
static bool _isValidConnectionJson(Map<String, dynamic> json) {
|
||||
// Check for required string fields
|
||||
if (json['protocol'] is! String || (json['protocol'] as String).isEmpty) {
|
||||
return false;
|
||||
}
|
||||
if (json['address'] is! String || (json['address'] as String).isEmpty) {
|
||||
return false;
|
||||
}
|
||||
if (json['uri'] is! String || (json['uri'] as String).isEmpty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for required port (integer)
|
||||
if (json['port'] is! int) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'protocol': protocol,
|
||||
@@ -565,3 +647,14 @@ class PlexConnection {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom exception for server parsing errors that includes debug data
|
||||
class ServerParsingException implements Exception {
|
||||
final String message;
|
||||
final List<Map<String, dynamic>> invalidServerData;
|
||||
|
||||
ServerParsingException(this.message, this.invalidServerData);
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hotkey_manager/hotkey_manager.dart';
|
||||
import 'package:plezy/utils/app_logger.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
|
||||
enum ThemeMode { system, light, dark }
|
||||
|
||||
@@ -37,6 +38,10 @@ class SettingsService {
|
||||
static const String _keySubtitleBorderColor = 'subtitle_border_color';
|
||||
static const String _keySubtitleBackgroundColor = 'subtitle_background_color';
|
||||
static const String _keySubtitleBackgroundOpacity = 'subtitle_background_opacity';
|
||||
static const String _keyShuffleUnwatchedOnly = 'shuffle_unwatched_only';
|
||||
static const String _keyShuffleOrderNavigation = 'shuffle_order_navigation';
|
||||
static const String _keyShuffleLoopQueue = 'shuffle_loop_queue';
|
||||
static const String _keyAppLocale = 'app_locale';
|
||||
|
||||
static SettingsService? _instance;
|
||||
late SharedPreferences _prefs;
|
||||
@@ -777,6 +782,50 @@ class SettingsService {
|
||||
}
|
||||
}
|
||||
|
||||
// App Locale
|
||||
Future<void> setAppLocale(AppLocale locale) async {
|
||||
await _prefs.setString(_keyAppLocale, locale.languageCode);
|
||||
}
|
||||
|
||||
AppLocale getAppLocale() {
|
||||
final localeString = _prefs.getString(_keyAppLocale);
|
||||
if (localeString == null) return AppLocale.en; // Default to English
|
||||
|
||||
return AppLocale.values.firstWhere(
|
||||
(locale) => locale.languageCode == localeString,
|
||||
orElse: () => AppLocale.en,
|
||||
);
|
||||
}
|
||||
|
||||
// Shuffle Play Settings
|
||||
|
||||
/// Shuffle Unwatched Only - Filter shuffle queue to unwatched episodes only
|
||||
Future<void> setShuffleUnwatchedOnly(bool enabled) async {
|
||||
await _prefs.setBool(_keyShuffleUnwatchedOnly, enabled);
|
||||
}
|
||||
|
||||
bool getShuffleUnwatchedOnly() {
|
||||
return _prefs.getBool(_keyShuffleUnwatchedOnly) ?? true; // Default: true
|
||||
}
|
||||
|
||||
/// Shuffle Order Navigation - Next/previous buttons follow shuffled order
|
||||
Future<void> setShuffleOrderNavigation(bool enabled) async {
|
||||
await _prefs.setBool(_keyShuffleOrderNavigation, enabled);
|
||||
}
|
||||
|
||||
bool getShuffleOrderNavigation() {
|
||||
return _prefs.getBool(_keyShuffleOrderNavigation) ?? true; // Default: true
|
||||
}
|
||||
|
||||
/// Shuffle Loop Queue - Restart queue when reaching the end
|
||||
Future<void> setShuffleLoopQueue(bool enabled) async {
|
||||
await _prefs.setBool(_keyShuffleLoopQueue, enabled);
|
||||
}
|
||||
|
||||
bool getShuffleLoopQueue() {
|
||||
return _prefs.getBool(_keyShuffleLoopQueue) ?? false; // Default: false
|
||||
}
|
||||
|
||||
// Reset all settings to defaults
|
||||
Future<void> resetAllSettings() async {
|
||||
await Future.wait([
|
||||
@@ -805,6 +854,10 @@ class SettingsService {
|
||||
_prefs.remove(_keySubtitleBorderColor),
|
||||
_prefs.remove(_keySubtitleBackgroundColor),
|
||||
_prefs.remove(_keySubtitleBackgroundOpacity),
|
||||
_prefs.remove(_keyShuffleUnwatchedOnly),
|
||||
_prefs.remove(_keyShuffleOrderNavigation),
|
||||
_prefs.remove(_keyShuffleLoopQueue),
|
||||
_prefs.remove(_keyAppLocale),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,14 +2,16 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../providers/playback_state_provider.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
import '../utils/video_player_navigation.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
|
||||
/// Handle shuffle play action for shows and seasons
|
||||
///
|
||||
/// Fetches all unwatched episodes, shuffles them, and starts playback
|
||||
/// from the first shuffled episode. The shuffle queue is stored in the
|
||||
/// PlaybackStateProvider for continuous shuffle playback.
|
||||
/// Fetches episodes based on user settings (unwatched only or including watched),
|
||||
/// shuffles them, and starts playback from the first shuffled episode.
|
||||
/// The shuffle queue is stored in the PlaybackStateProvider for continuous shuffle playback.
|
||||
Future<void> handleShufflePlay(
|
||||
BuildContext context,
|
||||
PlexMetadata metadata,
|
||||
@@ -18,8 +20,12 @@ Future<void> handleShufflePlay(
|
||||
if (client == null) return;
|
||||
|
||||
final playbackState = context.read<PlaybackStateProvider>();
|
||||
final settingsProvider = context.read<SettingsProvider>();
|
||||
final itemType = metadata.type.toLowerCase();
|
||||
|
||||
// Get shuffle setting
|
||||
final unwatchedOnly = settingsProvider.shuffleUnwatchedOnly;
|
||||
|
||||
try {
|
||||
// Show loading indicator
|
||||
if (context.mounted) {
|
||||
@@ -31,17 +37,44 @@ Future<void> handleShufflePlay(
|
||||
);
|
||||
}
|
||||
|
||||
// Get unwatched episodes based on type
|
||||
// Get episodes based on type and settings
|
||||
List<PlexMetadata> episodes;
|
||||
if (itemType == 'show') {
|
||||
episodes = await client.getAllUnwatchedEpisodes(
|
||||
metadata.ratingKey,
|
||||
);
|
||||
if (unwatchedOnly) {
|
||||
// Get only unwatched episodes
|
||||
episodes = await client.getAllUnwatchedEpisodes(
|
||||
metadata.ratingKey,
|
||||
);
|
||||
} else {
|
||||
// Get all episodes from all seasons
|
||||
final allEpisodes = <PlexMetadata>[];
|
||||
final seasons = await client.getChildren(metadata.ratingKey);
|
||||
|
||||
for (final season in seasons) {
|
||||
if (season.type == 'season') {
|
||||
final seasonEpisodes = await client.getChildren(season.ratingKey);
|
||||
final episodesOnly = seasonEpisodes
|
||||
.where((ep) => ep.type == 'episode')
|
||||
.toList();
|
||||
allEpisodes.addAll(episodesOnly);
|
||||
}
|
||||
}
|
||||
episodes = allEpisodes;
|
||||
}
|
||||
} else {
|
||||
// season
|
||||
episodes = await client.getUnwatchedEpisodesInSeason(
|
||||
metadata.ratingKey,
|
||||
);
|
||||
if (unwatchedOnly) {
|
||||
// Get only unwatched episodes
|
||||
episodes = await client.getUnwatchedEpisodesInSeason(
|
||||
metadata.ratingKey,
|
||||
);
|
||||
} else {
|
||||
// Get all episodes in season
|
||||
final seasonEpisodes = await client.getChildren(metadata.ratingKey);
|
||||
episodes = seasonEpisodes
|
||||
.where((ep) => ep.type == 'episode')
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
// Close loading indicator
|
||||
@@ -52,7 +85,7 @@ Future<void> handleShufflePlay(
|
||||
if (episodes.isEmpty) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('No unwatched episodes found')),
|
||||
SnackBar(content: Text(t.messages.noEpisodesFound)),
|
||||
);
|
||||
}
|
||||
return;
|
||||
@@ -76,7 +109,7 @@ Future<void> handleShufflePlay(
|
||||
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error starting shuffle play: $e')),
|
||||
SnackBar(content: Text(t.messages.errorLoading(error: e.toString()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/plex_home_user.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import 'provider_extensions.dart';
|
||||
|
||||
class UserSwitchingUtils {
|
||||
@@ -17,7 +18,7 @@ class UserSwitchingUtils {
|
||||
} else if (!success && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to switch to ${user.displayName}'),
|
||||
content: Text(t.messages.failedToSwitchProfile(displayName: user.displayName)),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../utils/platform_detector.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
|
||||
/// A menu action item for context menus
|
||||
class ContextMenuItem {
|
||||
@@ -67,14 +68,14 @@ class _ContextMenuWrapperState extends State<ContextMenuWrapper> {
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
child: Text(t.common.cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
style: isDestructive
|
||||
? TextButton.styleFrom(foregroundColor: Colors.red)
|
||||
: null,
|
||||
child: const Text('Confirm'),
|
||||
child: Text(t.common.confirm),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -165,10 +166,10 @@ class _ContextMenuWrapperState extends State<ContextMenuWrapper> {
|
||||
|
||||
if (selectedItem.requiresConfirmation) {
|
||||
final confirmed = await _showConfirmationDialog(
|
||||
title: selectedItem.confirmationTitle ?? 'Confirm Action',
|
||||
title: selectedItem.confirmationTitle ?? t.dialog.confirmAction,
|
||||
message:
|
||||
selectedItem.confirmationMessage ??
|
||||
'Are you sure you want to perform this action?',
|
||||
t.dialog.areYouSure,
|
||||
isDestructive: selectedItem.isDestructive,
|
||||
);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/plex_file_info.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
|
||||
class FileInfoBottomSheet extends StatelessWidget {
|
||||
final PlexFileInfo fileInfo;
|
||||
@@ -39,7 +40,7 @@ class FileInfoBottomSheet extends StatelessWidget {
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'File Info',
|
||||
t.fileInfo.title,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 20,
|
||||
@@ -74,56 +75,56 @@ class FileInfoBottomSheet extends StatelessWidget {
|
||||
],
|
||||
|
||||
// Video Section
|
||||
_buildSectionHeader('Video'),
|
||||
_buildSectionHeader(t.fileInfo.video),
|
||||
const SizedBox(height: 8),
|
||||
_buildInfoRow('Codec', fileInfo.videoCodec ?? 'Unknown'),
|
||||
_buildInfoRow('Resolution', fileInfo.resolutionFormatted),
|
||||
_buildInfoRow('Bitrate', fileInfo.bitrateFormatted),
|
||||
_buildInfoRow('Frame Rate', fileInfo.frameRateFormatted),
|
||||
_buildInfoRow('Aspect Ratio', fileInfo.aspectRatioFormatted),
|
||||
_buildInfoRow(t.fileInfo.codec, fileInfo.videoCodec ?? t.common.unknown),
|
||||
_buildInfoRow(t.fileInfo.resolution, fileInfo.resolutionFormatted),
|
||||
_buildInfoRow(t.fileInfo.bitrate, fileInfo.bitrateFormatted),
|
||||
_buildInfoRow(t.fileInfo.frameRate, fileInfo.frameRateFormatted),
|
||||
_buildInfoRow(t.fileInfo.aspectRatio, fileInfo.aspectRatioFormatted),
|
||||
if (fileInfo.videoProfile != null)
|
||||
_buildInfoRow('Profile', fileInfo.videoProfile!),
|
||||
_buildInfoRow(t.fileInfo.profile, fileInfo.videoProfile!),
|
||||
if (fileInfo.bitDepth != null)
|
||||
_buildInfoRow('Bit Depth', '${fileInfo.bitDepth} bit'),
|
||||
_buildInfoRow(t.fileInfo.bitDepth, '${fileInfo.bitDepth} bit'),
|
||||
if (fileInfo.colorSpace != null)
|
||||
_buildInfoRow('Color Space', fileInfo.colorSpace!),
|
||||
_buildInfoRow(t.fileInfo.colorSpace, fileInfo.colorSpace!),
|
||||
if (fileInfo.colorRange != null)
|
||||
_buildInfoRow('Color Range', fileInfo.colorRange!),
|
||||
_buildInfoRow(t.fileInfo.colorRange, fileInfo.colorRange!),
|
||||
if (fileInfo.colorPrimaries != null)
|
||||
_buildInfoRow('Color Primaries', fileInfo.colorPrimaries!),
|
||||
_buildInfoRow(t.fileInfo.colorPrimaries, fileInfo.colorPrimaries!),
|
||||
if (fileInfo.chromaSubsampling != null)
|
||||
_buildInfoRow('Chroma Subsampling', fileInfo.chromaSubsampling!),
|
||||
_buildInfoRow(t.fileInfo.chromaSubsampling, fileInfo.chromaSubsampling!),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Audio Section
|
||||
_buildSectionHeader('Audio'),
|
||||
_buildSectionHeader(t.fileInfo.audio),
|
||||
const SizedBox(height: 8),
|
||||
_buildInfoRow('Codec', fileInfo.audioCodec ?? 'Unknown'),
|
||||
_buildInfoRow('Channels', fileInfo.audioChannelsFormatted),
|
||||
_buildInfoRow(t.fileInfo.codec, fileInfo.audioCodec ?? t.common.unknown),
|
||||
_buildInfoRow(t.fileInfo.channels, fileInfo.audioChannelsFormatted),
|
||||
if (fileInfo.audioProfile != null)
|
||||
_buildInfoRow('Profile', fileInfo.audioProfile!),
|
||||
_buildInfoRow(t.fileInfo.profile, fileInfo.audioProfile!),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// File Section
|
||||
_buildSectionHeader('File'),
|
||||
_buildSectionHeader(t.fileInfo.file),
|
||||
const SizedBox(height: 8),
|
||||
if (fileInfo.filePath != null)
|
||||
_buildInfoRow('Path', fileInfo.filePath!, isMonospace: true),
|
||||
_buildInfoRow('Size', fileInfo.fileSizeFormatted),
|
||||
_buildInfoRow('Container', fileInfo.container ?? 'Unknown'),
|
||||
_buildInfoRow('Duration', fileInfo.durationFormatted),
|
||||
_buildInfoRow(t.fileInfo.path, fileInfo.filePath!, isMonospace: true),
|
||||
_buildInfoRow(t.fileInfo.size, fileInfo.fileSizeFormatted),
|
||||
_buildInfoRow(t.fileInfo.container, fileInfo.container ?? t.common.unknown),
|
||||
_buildInfoRow(t.fileInfo.duration, fileInfo.durationFormatted),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Advanced Section
|
||||
_buildSectionHeader('Advanced'),
|
||||
_buildSectionHeader(t.fileInfo.advanced),
|
||||
const SizedBox(height: 8),
|
||||
_buildInfoRow(
|
||||
'Optimized for Streaming',
|
||||
fileInfo.optimizedForStreaming == true ? 'Yes' : 'No',
|
||||
t.fileInfo.optimizedForStreaming,
|
||||
fileInfo.optimizedForStreaming == true ? t.common.yes : t.common.no,
|
||||
),
|
||||
_buildInfoRow(
|
||||
'64-bit Offsets',
|
||||
fileInfo.has64bitOffsets == true ? 'Yes' : 'No',
|
||||
t.fileInfo.has64bitOffsets,
|
||||
fileInfo.has64bitOffsets == true ? t.common.yes : t.common.no,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hotkey_manager/hotkey_manager.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
|
||||
class HotKeyRecorderWidget extends StatefulWidget {
|
||||
final String actionName;
|
||||
@@ -31,7 +32,7 @@ class _HotKeyRecorderWidgetState extends State<HotKeyRecorderWidget> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text('Set Shortcut for ${widget.actionName}'),
|
||||
title: Text(t.hotkeys.setShortcutFor(actionName: widget.actionName)),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: SingleChildScrollView(
|
||||
@@ -81,7 +82,7 @@ class _HotKeyRecorderWidgetState extends State<HotKeyRecorderWidget> {
|
||||
minWidth: 24,
|
||||
minHeight: 24,
|
||||
),
|
||||
tooltip: 'Clear shortcut',
|
||||
tooltip: t.hotkeys.clearShortcut,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -101,12 +102,12 @@ class _HotKeyRecorderWidgetState extends State<HotKeyRecorderWidget> {
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: widget.onCancel, child: const Text('Cancel')),
|
||||
TextButton(onPressed: widget.onCancel, child: Text(t.common.cancel)),
|
||||
TextButton(
|
||||
onPressed: _recordedHotKey != null
|
||||
? () => widget.onHotKeyRecorded(_recordedHotKey!)
|
||||
: null,
|
||||
child: const Text('Save'),
|
||||
child: Text(t.common.save),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ import '../utils/content_rating_formatter.dart';
|
||||
import '../screens/media_detail_screen.dart';
|
||||
import '../screens/season_detail_screen.dart';
|
||||
import '../theme/theme_helper.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import 'media_context_menu.dart';
|
||||
|
||||
class MediaCard extends StatefulWidget {
|
||||
@@ -44,9 +45,9 @@ class _MediaCardState extends State<MediaCard> {
|
||||
if (itemType == 'artist' || itemType == 'album' || itemType == 'track') {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Music playback is not yet supported'),
|
||||
duration: Duration(seconds: 2),
|
||||
SnackBar(
|
||||
content: Text(t.messages.musicNotSupported),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import '../screens/media_detail_screen.dart';
|
||||
import '../screens/season_detail_screen.dart';
|
||||
import '../widgets/file_info_bottom_sheet.dart';
|
||||
import '../utils/shuffle_play_helper.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
|
||||
/// Helper class to store menu action data
|
||||
class _MenuAction {
|
||||
@@ -66,7 +67,7 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
|
||||
_MenuAction(
|
||||
value: 'watch',
|
||||
icon: Icons.check_circle_outline,
|
||||
label: 'Mark as Watched',
|
||||
label: t.mediaMenu.markAsWatched,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -77,7 +78,7 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
|
||||
_MenuAction(
|
||||
value: 'unwatch',
|
||||
icon: Icons.remove_circle_outline,
|
||||
label: 'Mark as Unwatched',
|
||||
label: t.mediaMenu.markAsUnwatched,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -86,7 +87,7 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
|
||||
if ((itemType == 'episode' || itemType == 'season') &&
|
||||
widget.metadata.grandparentTitle != null) {
|
||||
menuActions.add(
|
||||
_MenuAction(value: 'series', icon: Icons.tv, label: 'Go to series'),
|
||||
_MenuAction(value: 'series', icon: Icons.tv, label: t.mediaMenu.goToSeries),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -96,7 +97,7 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
|
||||
_MenuAction(
|
||||
value: 'season',
|
||||
icon: Icons.playlist_play,
|
||||
label: 'Go to season',
|
||||
label: t.mediaMenu.goToSeason,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -107,7 +108,7 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
|
||||
_MenuAction(
|
||||
value: 'shuffle_play',
|
||||
icon: Icons.shuffle,
|
||||
label: 'Shuffle Play',
|
||||
label: t.mediaMenu.shufflePlay,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -118,7 +119,7 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
|
||||
_MenuAction(
|
||||
value: 'fileinfo',
|
||||
icon: Icons.info_outline,
|
||||
label: 'File Info',
|
||||
label: t.mediaMenu.fileInfo,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -214,7 +215,7 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
|
||||
await _executeAction(
|
||||
context,
|
||||
() => client.markAsWatched(widget.metadata.ratingKey),
|
||||
'Marked as watched',
|
||||
t.messages.markedAsWatched,
|
||||
);
|
||||
break;
|
||||
|
||||
@@ -222,7 +223,7 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
|
||||
await _executeAction(
|
||||
context,
|
||||
() => client.markAsUnwatched(widget.metadata.ratingKey),
|
||||
'Marked as unwatched',
|
||||
t.messages.markedAsUnwatched,
|
||||
);
|
||||
break;
|
||||
|
||||
@@ -231,7 +232,7 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
|
||||
context,
|
||||
widget.metadata.grandparentRatingKey,
|
||||
(metadata) => MediaDetailScreen(metadata: metadata),
|
||||
'Error loading series',
|
||||
t.messages.errorLoadingSeries,
|
||||
);
|
||||
break;
|
||||
|
||||
@@ -240,7 +241,7 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
|
||||
context,
|
||||
widget.metadata.parentRatingKey,
|
||||
(metadata) => SeasonDetailScreen(season: metadata),
|
||||
'Error loading season',
|
||||
t.messages.errorLoadingSeason,
|
||||
);
|
||||
break;
|
||||
|
||||
@@ -272,7 +273,7 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Error: $e')));
|
||||
).showSnackBar(SnackBar(content: Text(t.messages.errorLoading(error: e.toString()))));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -345,7 +346,7 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
|
||||
);
|
||||
} else if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('File information not available')),
|
||||
SnackBar(content: Text(t.messages.fileInfoNotAvailable)),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -357,7 +358,7 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Error loading file info: $e')));
|
||||
).showSnackBar(SnackBar(content: Text(t.messages.errorLoadingFileInfo(error: e.toString()))));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
|
||||
/// Dialog for entering a PIN to access a protected profile
|
||||
class PinEntryDialog extends StatefulWidget {
|
||||
@@ -110,7 +111,7 @@ class _PinEntryDialogState extends State<PinEntryDialog>
|
||||
LengthLimitingTextInputFormatter(10),
|
||||
],
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Enter PIN',
|
||||
hintText: t.pinEntry.enterPin,
|
||||
border: const OutlineInputBorder(),
|
||||
errorText: widget.errorMessage,
|
||||
errorMaxLines: 2,
|
||||
@@ -124,7 +125,7 @@ class _PinEntryDialogState extends State<PinEntryDialog>
|
||||
_obscureText = !_obscureText;
|
||||
});
|
||||
},
|
||||
tooltip: _obscureText ? 'Show PIN' : 'Hide PIN',
|
||||
tooltip: _obscureText ? t.pinEntry.showPin : t.pinEntry.hidePin,
|
||||
),
|
||||
),
|
||||
onSubmitted: (_) => _submit(),
|
||||
@@ -134,9 +135,9 @@ class _PinEntryDialogState extends State<PinEntryDialog>
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(null),
|
||||
child: const Text('Cancel'),
|
||||
child: Text(t.common.cancel),
|
||||
),
|
||||
FilledButton(onPressed: _submit, child: const Text('Submit')),
|
||||
FilledButton(onPressed: _submit, child: Text(t.common.submit)),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import '../models/plex_home_user.dart';
|
||||
import '../providers/user_profile_provider.dart';
|
||||
import '../utils/user_switching_utils.dart';
|
||||
import 'profile_list_tile.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
|
||||
class ProfileSwitchDialog extends StatelessWidget {
|
||||
const ProfileSwitchDialog({super.key});
|
||||
@@ -28,8 +29,8 @@ class ProfileSwitchDialog extends StatelessWidget {
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
else if (users.isEmpty)
|
||||
const Expanded(
|
||||
child: Center(child: Text('No users available')),
|
||||
Expanded(
|
||||
child: Center(child: Text(t.profile.noUsersAvailable)),
|
||||
)
|
||||
else
|
||||
Expanded(
|
||||
@@ -73,7 +74,7 @@ class ProfileSwitchDialog extends StatelessWidget {
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
child: Text(t.common.cancel),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/plex_auth_service.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
|
||||
class ServerListTile extends StatelessWidget {
|
||||
final PlexServer server;
|
||||
@@ -30,12 +31,12 @@ class ServerListTile extends StatelessWidget {
|
||||
color: isOnline
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5),
|
||||
semanticLabel: 'Server',
|
||||
semanticLabel: t.common.server,
|
||||
),
|
||||
title: Text(server.name),
|
||||
subtitle: Semantics(
|
||||
label:
|
||||
'${isOnline ? 'Online' : 'Offline'}, ${server.owned ? 'Owned' : 'Shared'}',
|
||||
'${isOnline ? t.common.online : t.common.offline}, ${server.owned ? t.common.owned : t.common.shared}',
|
||||
excludeSemantics: true,
|
||||
child: Row(
|
||||
children: [
|
||||
@@ -49,7 +50,7 @@ class ServerListTile extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
isOnline ? 'Online' : 'Offline',
|
||||
isOnline ? t.common.online : t.common.offline,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isOnline ? Colors.green : Colors.grey,
|
||||
@@ -70,7 +71,7 @@ class ServerListTile extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
server.owned ? 'Owned' : 'Shared',
|
||||
server.owned ? t.common.owned : t.common.shared,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
],
|
||||
@@ -84,7 +85,7 @@ class ServerListTile extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'CURRENT',
|
||||
t.common.current,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/plex_sort.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
|
||||
class SortBottomSheet extends StatefulWidget {
|
||||
final List<PlexSort> sortOptions;
|
||||
@@ -80,7 +81,7 @@ class _SortBottomSheetState extends State<SortBottomSheet> {
|
||||
if (widget.onClear != null)
|
||||
TextButton(
|
||||
onPressed: _handleClear,
|
||||
child: const Text('Clear'),
|
||||
child: Text(t.common.clear),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import '../models/plex_home_user.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
|
||||
class UserAvatarWidget extends StatelessWidget {
|
||||
final PlexHomeUser user;
|
||||
@@ -48,7 +49,7 @@ class UserAvatarWidget extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'Admin',
|
||||
t.userStatus.admin,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onPrimary,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -67,7 +68,7 @@ class UserAvatarWidget extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'Restricted',
|
||||
t.userStatus.restricted,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onPrimary,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -86,7 +87,7 @@ class UserAvatarWidget extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'Protected',
|
||||
t.userStatus.protected,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSecondary,
|
||||
fontWeight: FontWeight.bold,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:plezy/services/settings_service.dart';
|
||||
import '../../../i18n/strings.g.dart';
|
||||
|
||||
/// Bottom sheet for adjusting audio sync offset
|
||||
class AudioSyncSheet extends StatefulWidget {
|
||||
@@ -136,9 +137,9 @@ class _AudioSyncSheetState extends State<AudioSyncSheet> {
|
||||
// Slider
|
||||
Row(
|
||||
children: [
|
||||
const Text(
|
||||
'-2s',
|
||||
style: TextStyle(color: Colors.white70),
|
||||
Text(
|
||||
t.videoControls.minusTime(amount: "2", unit: "s"),
|
||||
style: const TextStyle(color: Colors.white70),
|
||||
),
|
||||
Expanded(
|
||||
child: Slider(
|
||||
@@ -158,9 +159,9 @@ class _AudioSyncSheetState extends State<AudioSyncSheet> {
|
||||
},
|
||||
),
|
||||
),
|
||||
const Text(
|
||||
'+2s',
|
||||
style: TextStyle(color: Colors.white70),
|
||||
Text(
|
||||
t.videoControls.addTime(amount: "2", unit: "s"),
|
||||
style: const TextStyle(color: Colors.white70),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -169,7 +170,7 @@ class _AudioSyncSheetState extends State<AudioSyncSheet> {
|
||||
ElevatedButton.icon(
|
||||
onPressed: _currentOffset != 0 ? _resetOffset : null,
|
||||
icon: const Icon(Icons.restart_alt),
|
||||
label: const Text('Reset to 0ms'),
|
||||
label: Text(t.videoControls.resetToZero),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.grey[800],
|
||||
foregroundColor: Colors.white,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import '../../../i18n/strings.g.dart';
|
||||
|
||||
/// Bottom sheet for selecting audio tracks
|
||||
class AudioTrackSheet extends StatelessWidget {
|
||||
@@ -53,9 +54,9 @@ class AudioTrackSheet extends StatelessWidget {
|
||||
children: [
|
||||
const Icon(Icons.audiotrack, color: Colors.white),
|
||||
const SizedBox(width: 12),
|
||||
const Text(
|
||||
'Audio Tracks',
|
||||
style: TextStyle(
|
||||
Text(
|
||||
t.videoControls.audioLabel,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import '../../../services/settings_service.dart';
|
||||
import '../../../services/sleep_timer_service.dart';
|
||||
import '../../../i18n/strings.g.dart';
|
||||
|
||||
/// Bottom sheet for sleep timer configuration
|
||||
class SleepTimerSheet extends StatelessWidget {
|
||||
@@ -135,7 +136,7 @@ class SleepTimerSheet extends StatelessWidget {
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('+15 min'),
|
||||
label: Text(t.videoControls.addTime(amount: "15", unit: " min")),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
side: const BorderSide(
|
||||
@@ -151,7 +152,7 @@ class SleepTimerSheet extends StatelessWidget {
|
||||
const SizedBox(width: 12),
|
||||
FilledButton.icon(
|
||||
icon: const Icon(Icons.cancel),
|
||||
label: const Text('Cancel'),
|
||||
label: Text(t.common.cancel),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
@@ -215,7 +216,7 @@ class SleepTimerSheet extends StatelessWidget {
|
||||
// Show confirmation snackbar
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Sleep timer set for $label'),
|
||||
content: Text(t.messages.sleepTimerSet(label: label)),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import '../../../i18n/strings.g.dart';
|
||||
|
||||
/// Bottom sheet for selecting subtitle tracks
|
||||
class SubtitleTrackSheet extends StatelessWidget {
|
||||
@@ -53,9 +54,9 @@ class SubtitleTrackSheet extends StatelessWidget {
|
||||
children: [
|
||||
const Icon(Icons.subtitles, color: Colors.white),
|
||||
const SizedBox(width: 12),
|
||||
const Text(
|
||||
'Subtitles',
|
||||
style: TextStyle(
|
||||
Text(
|
||||
t.videoControls.subtitlesLabel,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
|
||||
@@ -4,6 +4,7 @@ import '../../../services/settings_service.dart';
|
||||
import '../../../services/sleep_timer_service.dart';
|
||||
import '../../../utils/platform_detector.dart';
|
||||
import '../widgets/sync_offset_control.dart';
|
||||
import '../../../i18n/strings.g.dart';
|
||||
|
||||
enum _SettingsView { menu, speed, sleep, audioSync, subtitleSync, audioDevice }
|
||||
|
||||
@@ -431,7 +432,7 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('+15 min'),
|
||||
label: Text(t.videoControls.addTime(amount: "15", unit: " min")),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
side: const BorderSide(color: Colors.white54),
|
||||
@@ -443,7 +444,7 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
||||
const SizedBox(width: 12),
|
||||
FilledButton.icon(
|
||||
icon: const Icon(Icons.cancel),
|
||||
label: const Text('Cancel'),
|
||||
label: Text(t.common.cancel),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
@@ -493,7 +494,7 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
||||
Navigator.pop(context); // Close after selection
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Sleep timer set for $label'),
|
||||
content: Text(t.messages.sleepTimerSet(label: label)),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
@@ -513,7 +514,7 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
||||
player: widget.player,
|
||||
propertyName: 'audio-delay',
|
||||
initialOffset: _audioSyncOffset,
|
||||
labelText: 'Audio',
|
||||
labelText: t.videoControls.audioLabel,
|
||||
onOffsetChanged: (offset) async {
|
||||
final settings = await SettingsService.getInstance();
|
||||
await settings.setAudioSyncOffset(offset);
|
||||
@@ -529,7 +530,7 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
||||
player: widget.player,
|
||||
propertyName: 'sub-delay',
|
||||
initialOffset: _subtitleSyncOffset,
|
||||
labelText: 'Subtitles',
|
||||
labelText: t.videoControls.subtitlesLabel,
|
||||
onOffsetChanged: (offset) async {
|
||||
final settings = await SettingsService.getInstance();
|
||||
await settings.setSubtitleSyncOffset(offset);
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart' show SystemChrome, DeviceOrientation;
|
||||
import 'package:macos_window_utils/macos_window_utils.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
import 'package:macos_window_utils/macos_window_utils.dart';
|
||||
import '../../models/plex_metadata.dart';
|
||||
|
||||
import '../../models/plex_media_info.dart';
|
||||
import '../../models/plex_media_version.dart';
|
||||
import '../../models/plex_metadata.dart';
|
||||
import '../../screens/video_player_screen.dart';
|
||||
import '../../services/fullscreen_state_manager.dart';
|
||||
import '../../services/keyboard_shortcuts_service.dart';
|
||||
import '../../services/settings_service.dart';
|
||||
@@ -15,7 +18,7 @@ import '../../services/sleep_timer_service.dart';
|
||||
import '../../utils/desktop_window_padding.dart';
|
||||
import '../../utils/platform_detector.dart';
|
||||
import '../../utils/provider_extensions.dart';
|
||||
import '../../screens/video_player_screen.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
import '../app_bar_back_button.dart';
|
||||
import 'painters/chapter_marker_painter.dart';
|
||||
import 'sheets/audio_track_sheet.dart';
|
||||
@@ -92,15 +95,24 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
double _doubleTapFeedbackOpacity = 0.0;
|
||||
bool _lastDoubleTapWasForward = true;
|
||||
Timer? _feedbackTimer;
|
||||
// Seek throttle state
|
||||
Timer? _seekThrottleTimer;
|
||||
Duration? _pendingSeekPosition;
|
||||
// Current marker state
|
||||
PlexMarker? _currentMarker;
|
||||
List<PlexMarker> _markers = [];
|
||||
bool _markersLoaded = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focusNode = FocusNode();
|
||||
_loadChapters();
|
||||
_loadMarkers();
|
||||
_loadSeekTimes();
|
||||
_startHideTimer();
|
||||
_initKeyboardService();
|
||||
_listenToPosition();
|
||||
// Add lifecycle observer to reload settings when app resumes
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
// Add window listener for tracking fullscreen state (for button icon)
|
||||
@@ -113,6 +125,36 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
_keyboardService = await KeyboardShortcutsService.getInstance();
|
||||
}
|
||||
|
||||
void _listenToPosition() {
|
||||
widget.player.stream.position.listen((position) {
|
||||
if (_markers.isEmpty || !_markersLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
PlexMarker? foundMarker;
|
||||
for (final marker in _markers) {
|
||||
if (marker.containsPosition(position)) {
|
||||
foundMarker = marker;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (foundMarker != _currentMarker) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_currentMarker = foundMarker;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _skipMarker() {
|
||||
if (_currentMarker != null) {
|
||||
widget.player.seek(_currentMarker!.endTime);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadSeekTimes() async {
|
||||
final settingsService = await SettingsService.getInstance();
|
||||
if (mounted) {
|
||||
@@ -168,6 +210,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
void dispose() {
|
||||
_hideTimer?.cancel();
|
||||
_feedbackTimer?.cancel();
|
||||
_seekThrottleTimer?.cancel();
|
||||
_focusNode.dispose();
|
||||
// Remove lifecycle observer
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
@@ -305,6 +348,21 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadMarkers() async {
|
||||
final clientProvider = context.plexClient;
|
||||
final client = clientProvider.client;
|
||||
if (client == null) return;
|
||||
|
||||
final markers = await client.getMarkers(widget.metadata.ratingKey);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_markers = markers;
|
||||
_markersLoaded = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
bool _hasMultipleAudioTracks(Tracks? tracks) {
|
||||
if (tracks == null) return false;
|
||||
final audioTracks = tracks.audio
|
||||
@@ -337,13 +395,13 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
String _getBoxFitTooltip(int mode) {
|
||||
switch (mode) {
|
||||
case 0:
|
||||
return 'Letterbox';
|
||||
return t.videoControls.letterbox;
|
||||
case 1:
|
||||
return 'Fill screen';
|
||||
return t.videoControls.fillScreen;
|
||||
case 2:
|
||||
return 'Stretch';
|
||||
return t.videoControls.stretch;
|
||||
default:
|
||||
return 'Letterbox';
|
||||
return t.videoControls.letterbox;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -358,11 +416,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
|
||||
// Only apply SafeArea in portrait mode
|
||||
if (isPortrait) {
|
||||
return SafeArea(
|
||||
top: top,
|
||||
bottom: bottom,
|
||||
child: child,
|
||||
);
|
||||
return SafeArea(top: top, bottom: bottom, child: child);
|
||||
}
|
||||
|
||||
// In landscape, return child without SafeArea
|
||||
@@ -380,83 +434,90 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Unified settings button (speed, sleep timer, audio sync, subtitle sync)
|
||||
ListenableBuilder(
|
||||
listenable: SleepTimerService(),
|
||||
builder: (context, _) {
|
||||
final sleepTimer = SleepTimerService();
|
||||
final isActive =
|
||||
sleepTimer.isActive ||
|
||||
_audioSyncOffset != 0 ||
|
||||
_subtitleSyncOffset != 0;
|
||||
return VideoControlButton(
|
||||
icon: Icons.tune,
|
||||
isActive: isActive,
|
||||
onPressed: () async {
|
||||
await VideoSettingsSheet.show(
|
||||
context,
|
||||
widget.player,
|
||||
_audioSyncOffset,
|
||||
_subtitleSyncOffset,
|
||||
);
|
||||
// Sheet is now closed, reload immediately
|
||||
if (mounted) {
|
||||
await _loadSeekTimes();
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
if (_hasMultipleAudioTracks(tracks))
|
||||
VideoControlButton(
|
||||
icon: Icons.audiotrack,
|
||||
onPressed: () => AudioTrackSheet.show(context, widget.player),
|
||||
// Unified settings button (speed, sleep timer, audio sync, subtitle sync)
|
||||
ListenableBuilder(
|
||||
listenable: SleepTimerService(),
|
||||
builder: (context, _) {
|
||||
final sleepTimer = SleepTimerService();
|
||||
final isActive =
|
||||
sleepTimer.isActive ||
|
||||
_audioSyncOffset != 0 ||
|
||||
_subtitleSyncOffset != 0;
|
||||
return VideoControlButton(
|
||||
icon: Icons.tune,
|
||||
isActive: isActive,
|
||||
onPressed: () async {
|
||||
await VideoSettingsSheet.show(
|
||||
context,
|
||||
widget.player,
|
||||
_audioSyncOffset,
|
||||
_subtitleSyncOffset,
|
||||
);
|
||||
// Sheet is now closed, reload immediately
|
||||
if (mounted) {
|
||||
await _loadSeekTimes();
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
if (_hasSubtitles(tracks))
|
||||
VideoControlButton(
|
||||
icon: Icons.subtitles,
|
||||
onPressed: () => SubtitleTrackSheet.show(context, widget.player),
|
||||
),
|
||||
if (_chapters.isNotEmpty)
|
||||
VideoControlButton(
|
||||
icon: Icons.video_library,
|
||||
onPressed: () => ChapterSheet.show(
|
||||
context,
|
||||
widget.player,
|
||||
_chapters,
|
||||
_chaptersLoaded,
|
||||
if (_hasMultipleAudioTracks(tracks))
|
||||
VideoControlButton(
|
||||
icon: Icons.audiotrack,
|
||||
onPressed: () => AudioTrackSheet.show(context, widget.player),
|
||||
),
|
||||
),
|
||||
if (widget.availableVersions.length > 1)
|
||||
VideoControlButton(
|
||||
icon: Icons.video_file,
|
||||
onPressed: () => VersionSheet.show(
|
||||
context,
|
||||
widget.availableVersions,
|
||||
widget.selectedMediaIndex,
|
||||
_switchMediaVersion,
|
||||
if (_hasSubtitles(tracks))
|
||||
VideoControlButton(
|
||||
icon: Icons.subtitles,
|
||||
onPressed: () =>
|
||||
SubtitleTrackSheet.show(context, widget.player),
|
||||
),
|
||||
if (_chapters.isNotEmpty)
|
||||
VideoControlButton(
|
||||
icon: Icons.video_library,
|
||||
onPressed: () => ChapterSheet.show(
|
||||
context,
|
||||
widget.player,
|
||||
_chapters,
|
||||
_chaptersLoaded,
|
||||
),
|
||||
),
|
||||
if (widget.availableVersions.length > 1)
|
||||
VideoControlButton(
|
||||
icon: Icons.video_file,
|
||||
onPressed: () => VersionSheet.show(
|
||||
context,
|
||||
widget.availableVersions,
|
||||
widget.selectedMediaIndex,
|
||||
_switchMediaVersion,
|
||||
),
|
||||
),
|
||||
// BoxFit mode cycle button
|
||||
if (widget.onCycleBoxFitMode != null)
|
||||
VideoControlButton(
|
||||
icon: _getBoxFitIcon(widget.boxFitMode),
|
||||
tooltip: _getBoxFitTooltip(widget.boxFitMode),
|
||||
onPressed: widget.onCycleBoxFitMode,
|
||||
),
|
||||
// Rotation lock toggle (mobile only)
|
||||
if (PlatformDetector.isMobile(context))
|
||||
VideoControlButton(
|
||||
icon: _isRotationLocked
|
||||
? Icons.screen_lock_rotation
|
||||
: Icons.screen_rotation,
|
||||
tooltip: _isRotationLocked
|
||||
? t.videoControls.unlockRotation
|
||||
: t.videoControls.lockRotation,
|
||||
onPressed: _toggleRotationLock,
|
||||
),
|
||||
// Fullscreen toggle (desktop only)
|
||||
if (Platform.isWindows || Platform.isLinux || Platform.isMacOS)
|
||||
VideoControlButton(
|
||||
icon: _isFullscreen
|
||||
? Icons.fullscreen_exit
|
||||
: Icons.fullscreen,
|
||||
onPressed: _toggleFullscreen,
|
||||
),
|
||||
),
|
||||
// BoxFit mode cycle button
|
||||
if (widget.onCycleBoxFitMode != null)
|
||||
VideoControlButton(
|
||||
icon: _getBoxFitIcon(widget.boxFitMode),
|
||||
tooltip: _getBoxFitTooltip(widget.boxFitMode),
|
||||
onPressed: widget.onCycleBoxFitMode,
|
||||
),
|
||||
// Rotation lock toggle (mobile only)
|
||||
if (PlatformDetector.isMobile(context))
|
||||
VideoControlButton(
|
||||
icon: _isRotationLocked ? Icons.screen_lock_rotation : Icons.screen_rotation,
|
||||
tooltip: _isRotationLocked ? 'Unlock rotation' : 'Lock rotation',
|
||||
onPressed: _toggleRotationLock,
|
||||
),
|
||||
// Fullscreen toggle (desktop only)
|
||||
if (Platform.isWindows || Platform.isLinux || Platform.isMacOS)
|
||||
VideoControlButton(
|
||||
icon: _isFullscreen ? Icons.fullscreen_exit : Icons.fullscreen,
|
||||
onPressed: _toggleFullscreen,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -521,6 +582,40 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
widget.player.seek(clampedPosition);
|
||||
}
|
||||
|
||||
/// Throttled seek for timeline slider - only sends seek events at most every 100ms
|
||||
void _throttledSeek(Duration position) {
|
||||
// Store the pending position
|
||||
_pendingSeekPosition = position;
|
||||
|
||||
// If timer is already active, just update the pending position
|
||||
if (_seekThrottleTimer?.isActive ?? false) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Execute the seek immediately for the first call
|
||||
widget.player.seek(position);
|
||||
|
||||
// Start a timer to throttle subsequent seeks
|
||||
_seekThrottleTimer = Timer(const Duration(milliseconds: 200), () {
|
||||
// If there's a pending position that's different, execute it
|
||||
if (_pendingSeekPosition != null && _pendingSeekPosition != position) {
|
||||
widget.player.seek(_pendingSeekPosition!);
|
||||
}
|
||||
_pendingSeekPosition = null;
|
||||
});
|
||||
}
|
||||
|
||||
/// Finalizes the seek when user stops scrubbing the timeline
|
||||
void _finalizeSeek(Duration position) {
|
||||
// Cancel any pending throttled seek
|
||||
_seekThrottleTimer?.cancel();
|
||||
_seekThrottleTimer = null;
|
||||
|
||||
// Execute the final position immediately to ensure accuracy
|
||||
widget.player.seek(position);
|
||||
_pendingSeekPosition = null;
|
||||
}
|
||||
|
||||
/// Get the replay icon based on the duration
|
||||
/// Returns numbered icons (replay_5, replay_10, replay_30) when available,
|
||||
/// otherwise returns generic replay icon
|
||||
@@ -814,12 +909,74 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
),
|
||||
),
|
||||
),
|
||||
// Skip intro/credits button
|
||||
if (_currentMarker != null)
|
||||
Positioned(
|
||||
right: 24,
|
||||
bottom: isMobile ? 80 : 115,
|
||||
child: AnimatedOpacity(
|
||||
opacity: 1.0,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: _buildSkipMarkerButton(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSkipMarkerButton() {
|
||||
final isCredits = _currentMarker!.isCredits;
|
||||
final hasNextEpisode = widget.onNext != null;
|
||||
|
||||
// Show "Next Episode" for credits when next episode is available
|
||||
final bool showNextEpisode = isCredits && hasNextEpisode;
|
||||
final String buttonText = showNextEpisode
|
||||
? 'Next Episode'
|
||||
: (isCredits ? 'Skip Credits' : 'Skip Intro');
|
||||
final IconData buttonIcon = showNextEpisode
|
||||
? Icons.skip_next
|
||||
: Icons.fast_forward;
|
||||
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: showNextEpisode ? widget.onNext : _skipMarker,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.9),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.3),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
buttonText,
|
||||
style: const TextStyle(
|
||||
color: Colors.black,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(buttonIcon, color: Colors.black, size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMobileLayout() {
|
||||
return Column(
|
||||
children: [
|
||||
@@ -878,7 +1035,10 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
widget.metadata.index != null)
|
||||
Text(
|
||||
'S${widget.metadata.parentIndex} · E${widget.metadata.index} · ${widget.metadata.title}',
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 14),
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 14,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
@@ -1310,7 +1470,10 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
min: 0.0,
|
||||
max: duration.inMilliseconds.toDouble(),
|
||||
onChanged: (value) {
|
||||
widget.player.seek(Duration(milliseconds: value.toInt()));
|
||||
_throttledSeek(Duration(milliseconds: value.toInt()));
|
||||
},
|
||||
onChangeEnd: (value) {
|
||||
_finalizeSeek(Duration(milliseconds: value.toInt()));
|
||||
},
|
||||
activeColor: Colors.white,
|
||||
inactiveColor: Colors.white.withValues(alpha: 0.3),
|
||||
@@ -1407,7 +1570,8 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
final currentPosition = widget.player.state.position;
|
||||
|
||||
// Get state reference before async operations
|
||||
final videoPlayerState = context.findAncestorStateOfType<VideoPlayerScreenState>();
|
||||
final videoPlayerState = context
|
||||
.findAncestorStateOfType<VideoPlayerScreenState>();
|
||||
|
||||
// Save the preference
|
||||
final settingsService = await SettingsService.getInstance();
|
||||
@@ -1440,7 +1604,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Error switching version: $e')));
|
||||
).showSnackBar(SnackBar(content: Text(t.messages.errorLoading(error: e.toString()))));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import '../../../i18n/strings.g.dart';
|
||||
|
||||
/// Reusable widget for adjusting sync offsets (audio or subtitle)
|
||||
class SyncOffsetControl extends StatefulWidget {
|
||||
@@ -69,11 +70,11 @@ class _SyncOffsetControlState extends State<SyncOffsetControl> {
|
||||
|
||||
String _getDescriptionText() {
|
||||
if (_currentOffset > 0) {
|
||||
return '${widget.labelText} plays later';
|
||||
return t.videoControls.playsLater(label: widget.labelText);
|
||||
} else if (_currentOffset < 0) {
|
||||
return '${widget.labelText} plays earlier';
|
||||
return t.videoControls.playsEarlier(label: widget.labelText);
|
||||
} else {
|
||||
return 'No offset';
|
||||
return t.videoControls.noOffset;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +103,7 @@ class _SyncOffsetControlState extends State<SyncOffsetControl> {
|
||||
// Slider
|
||||
Row(
|
||||
children: [
|
||||
const Text('-2s', style: TextStyle(color: Colors.white70)),
|
||||
Text(t.videoControls.minusTime(amount: "2", unit: "s"), style: const TextStyle(color: Colors.white70)),
|
||||
Expanded(
|
||||
child: Slider(
|
||||
value: _currentOffset,
|
||||
@@ -121,7 +122,7 @@ class _SyncOffsetControlState extends State<SyncOffsetControl> {
|
||||
},
|
||||
),
|
||||
),
|
||||
const Text('+2s', style: TextStyle(color: Colors.white70)),
|
||||
Text(t.videoControls.addTime(amount: "2", unit: "s"), style: const TextStyle(color: Colors.white70)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
@@ -129,7 +130,7 @@ class _SyncOffsetControlState extends State<SyncOffsetControl> {
|
||||
ElevatedButton.icon(
|
||||
onPressed: _currentOffset != 0 ? _resetOffset : null,
|
||||
icon: const Icon(Icons.restart_alt),
|
||||
label: const Text('Reset to 0ms'),
|
||||
label: Text(t.videoControls.resetToZero),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.grey[800],
|
||||
foregroundColor: Colors.white,
|
||||
|
||||
@@ -5,12 +5,11 @@
|
||||
import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import audio_service
|
||||
import audio_session
|
||||
import hotkey_manager_macos
|
||||
import macos_window_utils
|
||||
import media_kit_libs_macos_video
|
||||
import media_kit_video
|
||||
import os_media_controls
|
||||
import package_info_plus
|
||||
import path_provider_foundation
|
||||
import screen_retriever_macos
|
||||
@@ -22,12 +21,11 @@ import wakelock_plus
|
||||
import window_manager
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
AudioServicePlugin.register(with: registry.registrar(forPlugin: "AudioServicePlugin"))
|
||||
AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin"))
|
||||
HotkeyManagerMacosPlugin.register(with: registry.registrar(forPlugin: "HotkeyManagerMacosPlugin"))
|
||||
MacOSWindowUtilsPlugin.register(with: registry.registrar(forPlugin: "MacOSWindowUtilsPlugin"))
|
||||
MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin"))
|
||||
MediaKitVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitVideoPlugin"))
|
||||
OsMediaControlsPlugin.register(with: registry.registrar(forPlugin: "OsMediaControlsPlugin"))
|
||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||
ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin"))
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
PODS:
|
||||
- audio_service (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- audio_session (0.0.1):
|
||||
- FlutterMacOS
|
||||
- FlutterMacOS (1.0.0)
|
||||
- HotKey (0.2.1)
|
||||
- hotkey_manager_macos (0.0.1):
|
||||
@@ -15,6 +10,8 @@ PODS:
|
||||
- FlutterMacOS
|
||||
- media_kit_video (0.0.1):
|
||||
- FlutterMacOS
|
||||
- os_media_controls (0.0.1):
|
||||
- FlutterMacOS
|
||||
- package_info_plus (0.0.1):
|
||||
- FlutterMacOS
|
||||
- path_provider_foundation (0.0.1):
|
||||
@@ -38,13 +35,12 @@ PODS:
|
||||
- FlutterMacOS
|
||||
|
||||
DEPENDENCIES:
|
||||
- audio_service (from `Flutter/ephemeral/.symlinks/plugins/audio_service/darwin`)
|
||||
- audio_session (from `Flutter/ephemeral/.symlinks/plugins/audio_session/macos`)
|
||||
- FlutterMacOS (from `Flutter/ephemeral`)
|
||||
- hotkey_manager_macos (from `Flutter/ephemeral/.symlinks/plugins/hotkey_manager_macos/macos`)
|
||||
- macos_window_utils (from `Flutter/ephemeral/.symlinks/plugins/macos_window_utils/macos`)
|
||||
- media_kit_libs_macos_video (from `Flutter/ephemeral/.symlinks/plugins/media_kit_libs_macos_video/macos`)
|
||||
- media_kit_video (from `Flutter/ephemeral/.symlinks/plugins/media_kit_video/macos`)
|
||||
- os_media_controls (from `Flutter/ephemeral/.symlinks/plugins/os_media_controls/macos`)
|
||||
- package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`)
|
||||
- path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`)
|
||||
- screen_retriever_macos (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos`)
|
||||
@@ -60,10 +56,6 @@ SPEC REPOS:
|
||||
- HotKey
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
audio_service:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/audio_service/darwin
|
||||
audio_session:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/audio_session/macos
|
||||
FlutterMacOS:
|
||||
:path: Flutter/ephemeral
|
||||
hotkey_manager_macos:
|
||||
@@ -74,6 +66,8 @@ EXTERNAL SOURCES:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/media_kit_libs_macos_video/macos
|
||||
media_kit_video:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/media_kit_video/macos
|
||||
os_media_controls:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/os_media_controls/macos
|
||||
package_info_plus:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos
|
||||
path_provider_foundation:
|
||||
@@ -94,14 +88,13 @@ EXTERNAL SOURCES:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
audio_service: aa99a6ba2ae7565996015322b0bb024e1d25c6fd
|
||||
audio_session: eaca2512cf2b39212d724f35d11f46180ad3a33e
|
||||
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
|
||||
HotKey: 400beb7caa29054ea8d864c96f5ba7e5b4852277
|
||||
hotkey_manager_macos: a4317849af96d2430fa89944d3c58977ca089fbe
|
||||
macos_window_utils: 23f54331a0fd51eea9e0ed347253bf48fd379d1d
|
||||
media_kit_libs_macos_video: 85a23e549b5f480e72cae3e5634b5514bc692f65
|
||||
media_kit_video: fa6564e3799a0a28bff39442334817088b7ca758
|
||||
os_media_controls: c07c04c4afdf59dda0a3f398457a46823c4ce0ed
|
||||
package_info_plus: f0052d280d17aa382b932f399edf32507174e870
|
||||
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
|
||||
screen_retriever_macos: 452e51764a9e1cdb74b3c541238795849f21557f
|
||||
|
||||
@@ -21,14 +21,14 @@
|
||||
/* End PBXAggregateTarget section */
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
0222508F6EE2A41A69A621F6 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD0B314F22FE6D00D0C7673 /* Pods_RunnerTests.framework */; };
|
||||
331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; };
|
||||
335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; };
|
||||
33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; };
|
||||
33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; };
|
||||
33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; };
|
||||
6AC86ED72EA70B4C0067BC66 /* plezy.icon in Resources */ = {isa = PBXBuildFile; fileRef = 6AC86ED62EA70B4C0067BC66 /* plezy.icon */; };
|
||||
A3BC036FAB4B8E39B7126970 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 202C1CA899D4FEDC5F1A3000 /* Pods_Runner.framework */; };
|
||||
9AECA605E2E1BECE3CA5BD01 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 78F9F23331B540A25C4A70C7 /* Pods_Runner.framework */; };
|
||||
FE52EE1D2D489FA88507D14E /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 84260356652A3270E664FED4 /* Pods_RunnerTests.framework */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -62,10 +62,7 @@
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
053753D375D440BB1EC6B342 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
202C1CA899D4FEDC5F1A3000 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
2BE1BA94C203ACCD120F0127 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
2EDB448AB472FA47ED7BA245 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
||||
21731DD0518FC0A0720FA340 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
||||
333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = "<group>"; };
|
||||
@@ -81,13 +78,16 @@
|
||||
33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = "<group>"; };
|
||||
33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = "<group>"; };
|
||||
33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = "<group>"; };
|
||||
5CD0B314F22FE6D00D0C7673 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
5F3D7650F7C0FBE711045DD4 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
4E4BFEE5F31FF1CC308F1693 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
||||
5EBEC2B546108DF45BEB0B00 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
6AC86ED62EA70B4C0067BC66 /* plezy.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = plezy.icon; sourceTree = "<group>"; };
|
||||
78F9F23331B540A25C4A70C7 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
|
||||
84260356652A3270E664FED4 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
8EA2FC77E7FECB9A7FA97A34 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
|
||||
EB84EE33D6E7F697C803E14A /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
|
||||
EFA1AE35E23B7995D6C62CFD /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
B69F8D562E2B39315A1369D6 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
|
||||
B733A8329CDDCE38392B9494 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -95,7 +95,7 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
0222508F6EE2A41A69A621F6 /* Pods_RunnerTests.framework in Frameworks */,
|
||||
FE52EE1D2D489FA88507D14E /* Pods_RunnerTests.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -103,7 +103,7 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
A3BC036FAB4B8E39B7126970 /* Pods_Runner.framework in Frameworks */,
|
||||
9AECA605E2E1BECE3CA5BD01 /* Pods_Runner.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -113,12 +113,12 @@
|
||||
295400F6A94D53E806ED64A8 /* Pods */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2BE1BA94C203ACCD120F0127 /* Pods-Runner.debug.xcconfig */,
|
||||
2EDB448AB472FA47ED7BA245 /* Pods-Runner.release.xcconfig */,
|
||||
5F3D7650F7C0FBE711045DD4 /* Pods-Runner.profile.xcconfig */,
|
||||
053753D375D440BB1EC6B342 /* Pods-RunnerTests.debug.xcconfig */,
|
||||
EB84EE33D6E7F697C803E14A /* Pods-RunnerTests.release.xcconfig */,
|
||||
EFA1AE35E23B7995D6C62CFD /* Pods-RunnerTests.profile.xcconfig */,
|
||||
21731DD0518FC0A0720FA340 /* Pods-Runner.debug.xcconfig */,
|
||||
4E4BFEE5F31FF1CC308F1693 /* Pods-Runner.release.xcconfig */,
|
||||
8EA2FC77E7FECB9A7FA97A34 /* Pods-Runner.profile.xcconfig */,
|
||||
B733A8329CDDCE38392B9494 /* Pods-RunnerTests.debug.xcconfig */,
|
||||
B69F8D562E2B39315A1369D6 /* Pods-RunnerTests.release.xcconfig */,
|
||||
5EBEC2B546108DF45BEB0B00 /* Pods-RunnerTests.profile.xcconfig */,
|
||||
);
|
||||
path = Pods;
|
||||
sourceTree = "<group>";
|
||||
@@ -150,8 +150,8 @@
|
||||
33CEB47122A05771004F2AC0 /* Flutter */,
|
||||
331C80D6294CF71000263BE5 /* RunnerTests */,
|
||||
33CC10EE2044A3C60003C045 /* Products */,
|
||||
D73912EC22F37F3D000D13A0 /* Frameworks */,
|
||||
295400F6A94D53E806ED64A8 /* Pods */,
|
||||
36FC208C02D36438C31F4842 /* Frameworks */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -198,11 +198,11 @@
|
||||
path = Runner;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D73912EC22F37F3D000D13A0 /* Frameworks */ = {
|
||||
36FC208C02D36438C31F4842 /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
202C1CA899D4FEDC5F1A3000 /* Pods_Runner.framework */,
|
||||
5CD0B314F22FE6D00D0C7673 /* Pods_RunnerTests.framework */,
|
||||
78F9F23331B540A25C4A70C7 /* Pods_Runner.framework */,
|
||||
84260356652A3270E664FED4 /* Pods_RunnerTests.framework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
@@ -214,7 +214,7 @@
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
|
||||
buildPhases = (
|
||||
2CF269D31FC1E8FDDD181D81 /* [CP] Check Pods Manifest.lock */,
|
||||
2969483E1AB2CCAF8910A74F /* [CP] Check Pods Manifest.lock */,
|
||||
331C80D1294CF70F00263BE5 /* Sources */,
|
||||
331C80D2294CF70F00263BE5 /* Frameworks */,
|
||||
331C80D3294CF70F00263BE5 /* Resources */,
|
||||
@@ -233,13 +233,13 @@
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||
buildPhases = (
|
||||
99EAC5D8B082B1DBE925E26C /* [CP] Check Pods Manifest.lock */,
|
||||
B3C3284135875B6B88373CF1 /* [CP] Check Pods Manifest.lock */,
|
||||
33CC10E92044A3C60003C045 /* Sources */,
|
||||
33CC10EA2044A3C60003C045 /* Frameworks */,
|
||||
33CC10EB2044A3C60003C045 /* Resources */,
|
||||
33CC110E2044A8840003C045 /* Bundle Framework */,
|
||||
3399D490228B24CF009A79C7 /* ShellScript */,
|
||||
1061B01F01C2AD298FA23F27 /* [CP] Embed Pods Frameworks */,
|
||||
246382E10D2DDCBEF8E02166 /* [CP] Embed Pods Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -322,7 +322,7 @@
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
1061B01F01C2AD298FA23F27 /* [CP] Embed Pods Frameworks */ = {
|
||||
246382E10D2DDCBEF8E02166 /* [CP] Embed Pods Frameworks */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
@@ -339,7 +339,7 @@
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
2CF269D31FC1E8FDDD181D81 /* [CP] Check Pods Manifest.lock */ = {
|
||||
2969483E1AB2CCAF8910A74F /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
@@ -399,7 +399,7 @@
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire";
|
||||
};
|
||||
99EAC5D8B082B1DBE925E26C /* [CP] Check Pods Manifest.lock */ = {
|
||||
B3C3284135875B6B88373CF1 /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
@@ -472,7 +472,7 @@
|
||||
/* Begin XCBuildConfiguration section */
|
||||
331C80DB294CF71000263BE5 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 053753D375D440BB1EC6B342 /* Pods-RunnerTests.debug.xcconfig */;
|
||||
baseConfigurationReference = B733A8329CDDCE38392B9494 /* Pods-RunnerTests.debug.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
@@ -487,7 +487,7 @@
|
||||
};
|
||||
331C80DC294CF71000263BE5 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = EB84EE33D6E7F697C803E14A /* Pods-RunnerTests.release.xcconfig */;
|
||||
baseConfigurationReference = B69F8D562E2B39315A1369D6 /* Pods-RunnerTests.release.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
@@ -502,7 +502,7 @@
|
||||
};
|
||||
331C80DD294CF71000263BE5 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = EFA1AE35E23B7995D6C62CFD /* Pods-RunnerTests.profile.xcconfig */;
|
||||
baseConfigurationReference = 5EBEC2B546108DF45BEB0B00 /* Pods-RunnerTests.profile.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
|
||||
@@ -5,18 +5,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: _fe_analyzer_shared
|
||||
sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d
|
||||
sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "91.0.0"
|
||||
version: "85.0.0"
|
||||
analyzer:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: analyzer
|
||||
sha256: f51c8499b35f9b26820cfe914828a6a98a94efd5cc78b37bb7d03debae3a1d08
|
||||
sha256: "974859dc0ff5f37bc4313244b3218c791810d03ab3470a579580279ba971a48d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.4.1"
|
||||
version: "7.7.1"
|
||||
archive:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -41,38 +41,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.0"
|
||||
audio_service:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: audio_service
|
||||
sha256: cb122c7c2639d2a992421ef96b67948ad88c5221da3365ccef1031393a76e044
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.18.18"
|
||||
audio_service_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: audio_service_platform_interface
|
||||
sha256: "6283782851f6c8b501b60904a32fc7199dc631172da0629d7301e66f672ab777"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.3"
|
||||
audio_service_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: audio_service_web
|
||||
sha256: b8ea9243201ee53383157fbccf13d5d2a866b5dda922ec19d866d1d5d70424df
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.4"
|
||||
audio_session:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: audio_session
|
||||
sha256: "8f96a7fecbb718cb093070f868b4cdcb8a9b1053dce342ff8ab2fde10eb9afb7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.2"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -85,18 +53,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build
|
||||
sha256: dfb67ccc9a78c642193e0c2d94cb9e48c2c818b3178a86097d644acdcde6a8d9
|
||||
sha256: "51dc711996cbf609b90cbe5b335bbce83143875a9d58e4b5c6d3c4f684d3dda7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.2"
|
||||
version: "2.5.4"
|
||||
build_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build_config
|
||||
sha256: "4f64382b97504dc2fcdf487d5aae33418e08b4703fc21249e4db6d804a4d0187"
|
||||
sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.0"
|
||||
version: "1.1.2"
|
||||
build_daemon:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -105,14 +73,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.0"
|
||||
build_resolvers:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build_resolvers
|
||||
sha256: ee4257b3f20c0c90e72ed2b57ad637f694ccba48839a821e87db762548c22a62
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.4"
|
||||
build_runner:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: build_runner
|
||||
sha256: a9461b8e586bf018dd4afd2e13b49b08c6a844a4b226c8d1d10f3a723cdd78c3
|
||||
sha256: "382a4d649addbfb7ba71a3631df0ec6a45d5ab9b098638144faf27f02778eb53"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.10.1"
|
||||
version: "2.5.4"
|
||||
build_runner_core:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build_runner_core
|
||||
sha256: "85fbbb1036d576d966332a3f5ce83f2ce66a40bea1a94ad2d5fc29a19a0d3792"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.1.2"
|
||||
built_collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -217,14 +201,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
csv:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: csv
|
||||
sha256: c6aa2679b2a18cb57652920f674488d89712efaf4d3fdf2e537215b35fc19d6c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
dart_style:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dart_style
|
||||
sha256: c87dfe3d56f183ffe9106a18aebc6db431fc7c98c31a54b952a77f3d54a85697
|
||||
sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
version: "3.1.1"
|
||||
dbus:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -336,6 +328,14 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
frontend_server_client:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: frontend_server_client
|
||||
sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
glob:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -440,6 +440,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.2"
|
||||
json2yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: json2yaml
|
||||
sha256: da94630fbc56079426fdd167ae58373286f603371075b69bf46d848d63ba3e51
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.1"
|
||||
json_annotation:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -452,10 +460,10 @@ packages:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: json_serializable
|
||||
sha256: "33a040668b31b320aafa4822b7b1e177e163fc3c1e835c6750319d4ab23aa6fe"
|
||||
sha256: c50ef5fc083d5b5e12eef489503ba3bf5ccc899e487d691584699b4bdefeea8c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.11.1"
|
||||
version: "6.9.5"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -532,59 +540,65 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: media_kit
|
||||
ref: custom-mpv-config
|
||||
resolved-ref: fd7bd79a9ba691306038c61791ea5754a5287d8a
|
||||
ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5"
|
||||
resolved-ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5"
|
||||
url: "https://github.com/edde746/media-kit"
|
||||
source: git
|
||||
version: "1.2.1"
|
||||
media_kit_libs_android_video:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: media_kit_libs_android_video
|
||||
sha256: "3f6274e5ab2de512c286a25c327288601ee445ed8ac319e0ef0b66148bd8f76c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.8"
|
||||
path: "libs/android/media_kit_libs_android_video"
|
||||
ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5"
|
||||
resolved-ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5"
|
||||
url: "https://github.com/edde746/media-kit"
|
||||
source: git
|
||||
version: "1.3.9"
|
||||
media_kit_libs_ios_video:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: media_kit_libs_ios_video
|
||||
sha256: b5382994eb37a4564c368386c154ad70ba0cc78dacdd3fb0cd9f30db6d837991
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
path: "libs/ios/media_kit_libs_ios_video"
|
||||
ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5"
|
||||
resolved-ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5"
|
||||
url: "https://github.com/edde746/media-kit"
|
||||
source: git
|
||||
version: "1.1.4"
|
||||
media_kit_libs_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: media_kit_libs_linux
|
||||
sha256: "2b473399a49ec94452c4d4ae51cfc0f6585074398d74216092bf3d54aac37ecf"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
path: "libs/linux/media_kit_libs_linux"
|
||||
ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5"
|
||||
resolved-ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5"
|
||||
url: "https://github.com/edde746/media-kit"
|
||||
source: git
|
||||
version: "1.2.1"
|
||||
media_kit_libs_macos_video:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: media_kit_libs_macos_video
|
||||
sha256: f26aa1452b665df288e360393758f84b911f70ffb3878032e1aabba23aa1032d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.4"
|
||||
path: "libs/macos/media_kit_libs_macos_video"
|
||||
ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5"
|
||||
resolved-ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5"
|
||||
url: "https://github.com/edde746/media-kit"
|
||||
source: git
|
||||
version: "1.1.5"
|
||||
media_kit_libs_video:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: media_kit_libs_video
|
||||
sha256: "2b235b5dac79c6020e01eef5022c6cc85fedc0df1738aadc6ea489daa12a92a9"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
path: "libs/universal/media_kit_libs_video"
|
||||
ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5"
|
||||
resolved-ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5"
|
||||
url: "https://github.com/edde746/media-kit"
|
||||
source: git
|
||||
version: "1.0.7"
|
||||
media_kit_libs_windows_video:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: media_kit_libs_windows_video
|
||||
sha256: dff76da2778729ab650229e6b4ec6ec111eb5151431002cbd7ea304ff1f112ab
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.11"
|
||||
path: "libs/windows/media_kit_libs_windows_video"
|
||||
ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5"
|
||||
resolved-ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5"
|
||||
url: "https://github.com/edde746/media-kit"
|
||||
source: git
|
||||
version: "1.0.12"
|
||||
media_kit_video:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -625,6 +639,15 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
os_media_controls:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: "53d803e1c228eb7eab9ca642119d404c21f4a52f"
|
||||
resolved-ref: "53d803e1c228eb7eab9ca642119d404c21f4a52f"
|
||||
url: "https://github.com/edde746/os-media-controls"
|
||||
source: git
|
||||
version: "0.0.2"
|
||||
package_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -769,14 +792,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.0"
|
||||
qr:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: qr
|
||||
sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
qr_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: qr_flutter
|
||||
sha256: "5095f0fc6e3f71d08adef8feccc8cea4f12eec18a2e31c2e8d82cb6019f4b097"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.0"
|
||||
rxdart:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: rxdart
|
||||
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
|
||||
sha256: "0c7c0cedd93788d996e33041ffecda924cc54389199cde4e6a34b440f50044cb"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.28.0"
|
||||
version: "0.27.7"
|
||||
safe_local_storage:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -918,22 +957,46 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
slang:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: slang
|
||||
sha256: a466773de768eb95bdf681e0a92e7c8010d44bb247b62130426c83ece33aeaed
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.32.0"
|
||||
slang_build_runner:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: slang_build_runner
|
||||
sha256: b2e0c63f3c801a4aa70b4ca43173893d6eb7d5a421fc9d97ad983527397631b3
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.32.0"
|
||||
slang_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: slang_flutter
|
||||
sha256: "1a98e878673996902fa5ef0b61ce5c245e41e4d25640d18af061c6aab917b0c7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.32.0"
|
||||
source_gen:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_gen
|
||||
sha256: "9098ab86015c4f1d8af6486b547b11100e73b193e1899015033cb3e14ad20243"
|
||||
sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.2"
|
||||
version: "2.0.0"
|
||||
source_helper:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_helper
|
||||
sha256: "6a3c6cc82073a8797f8c4dc4572146114a39652851c157db37e964d9c7038723"
|
||||
sha256: a447acb083d3a5ef17f983dd36201aeea33fedadb3228fa831f2f0c92f0f3aca
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.8"
|
||||
version: "1.3.7"
|
||||
source_span:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1038,6 +1101,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.6"
|
||||
timing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: timing
|
||||
sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1255,5 +1326,5 @@ packages:
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.9.0 <4.0.0"
|
||||
dart: ">=3.9.2 <4.0.0"
|
||||
flutter: ">=3.35.0"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: plezy
|
||||
description: "A beautiful Plex client for Flutter"
|
||||
publish_to: "none"
|
||||
version: 1.5.1+13
|
||||
version: 1.6.1+15
|
||||
|
||||
environment:
|
||||
sdk: ^3.8.1
|
||||
@@ -25,16 +25,25 @@ dependencies:
|
||||
provider: ^6.1.2
|
||||
hotkey_manager: ^0.2.3
|
||||
flex_color_picker: ^3.6.0
|
||||
audio_service: ^0.18.18
|
||||
audio_session: ^0.2.1
|
||||
qr_flutter: ^4.1.0
|
||||
slang: ^3.31.2
|
||||
slang_flutter: ^3.31.0
|
||||
os_media_controls:
|
||||
git:
|
||||
url: https://github.com/edde746/os-media-controls
|
||||
ref: 53d803e1c228eb7eab9ca642119d404c21f4a52f
|
||||
|
||||
dependency_overrides:
|
||||
media_kit:
|
||||
git:
|
||||
url: https://github.com/edde746/media-kit
|
||||
ref: custom-mpv-config
|
||||
ref: 9782771486c0356b48c2e31e47365d1ed0b7fcb5
|
||||
path: media_kit
|
||||
media_kit_libs_video:
|
||||
git:
|
||||
url: https://github.com/edde746/media-kit
|
||||
ref: 9782771486c0356b48c2e31e47365d1ed0b7fcb5
|
||||
path: libs/universal/media_kit_libs_video
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
@@ -43,6 +52,7 @@ dev_dependencies:
|
||||
build_runner: ^2.4.7
|
||||
json_serializable: ^6.7.1
|
||||
flutter_launcher_icons: ^0.14.4
|
||||
slang_build_runner: ^3.31.0
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Android Icon Generator Script
|
||||
# Generates notification icons and monochrome launcher icons from SVG source
|
||||
# Usage: ./generate_android_icons.sh
|
||||
|
||||
set -e
|
||||
|
||||
# Configuration
|
||||
SVG_SOURCE="assets/plezy.svg"
|
||||
ANDROID_RES="android/app/src/main/res"
|
||||
TEMP_DIR="/tmp/android_icons_$$"
|
||||
|
||||
# Check if source SVG exists
|
||||
if [ ! -f "$SVG_SOURCE" ]; then
|
||||
echo "Error: $SVG_SOURCE not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check for required tools
|
||||
if ! command -v rsvg-convert &> /dev/null; then
|
||||
echo "Error: rsvg-convert not found. Install with: brew install librsvg"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v magick &> /dev/null && ! command -v convert &> /dev/null; then
|
||||
echo "Error: ImageMagick not found. Install with: brew install imagemagick"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create temp directory
|
||||
mkdir -p "$TEMP_DIR"
|
||||
|
||||
echo "🎨 Generating Android icons from $SVG_SOURCE..."
|
||||
|
||||
# Function to generate white silhouette icon
|
||||
generate_white_icon() {
|
||||
local size=$1
|
||||
local output=$2
|
||||
|
||||
# Get SVG dimensions to detect if it's non-square
|
||||
local svg_viewbox=$(grep -o 'viewBox="[^"]*"' "$SVG_SOURCE" | sed 's/viewBox="//;s/"//')
|
||||
local svg_width=$(echo "$svg_viewbox" | awk '{print $3}')
|
||||
local svg_height=$(echo "$svg_viewbox" | awk '{print $4}')
|
||||
|
||||
# Convert SVG to PNG, preserving aspect ratio
|
||||
if (( $(echo "$svg_width != $svg_height" | bc -l) )); then
|
||||
# Non-square SVG: render at size and add padding to center it
|
||||
rsvg-convert --keep-aspect-ratio --background-color=transparent "$SVG_SOURCE" -o "$TEMP_DIR/temp_unpadded.png"
|
||||
|
||||
# Center the image in a square canvas with transparent padding
|
||||
magick "$TEMP_DIR/temp_unpadded.png" \
|
||||
-resize "${size}x${size}" \
|
||||
-gravity center \
|
||||
-background transparent \
|
||||
-extent "${size}x${size}" \
|
||||
"$TEMP_DIR/temp.png"
|
||||
else
|
||||
# Square SVG: convert directly
|
||||
rsvg-convert -w "$size" -h "$size" --background-color=transparent "$SVG_SOURCE" -o "$TEMP_DIR/temp.png"
|
||||
fi
|
||||
|
||||
# Convert to white silhouette: extract alpha, fill with white, apply alpha
|
||||
magick "$TEMP_DIR/temp.png" \
|
||||
-alpha extract \
|
||||
"$TEMP_DIR/alpha_mask.png"
|
||||
|
||||
magick -size "${size}x${size}" xc:white \
|
||||
"$TEMP_DIR/alpha_mask.png" \
|
||||
-alpha off \
|
||||
-compose copy-opacity \
|
||||
-composite \
|
||||
-define png:color-type=6 \
|
||||
"$output"
|
||||
|
||||
echo " ✓ Generated $(basename $output) (${size}x${size}px)"
|
||||
}
|
||||
|
||||
# Generate Notification Icons (24dp base)
|
||||
echo ""
|
||||
echo "📱 Generating notification icons (ic_stat_notification.png)..."
|
||||
|
||||
# Notification icon densities: 24dp base
|
||||
# mdpi=1x, hdpi=1.5x, xhdpi=2x, xxhdpi=3x, xxxhdpi=4x
|
||||
declare -A NOTIF_SIZES=(
|
||||
["mdpi"]=24
|
||||
["hdpi"]=36
|
||||
["xhdpi"]=48
|
||||
["xxhdpi"]=72
|
||||
["xxxhdpi"]=96
|
||||
)
|
||||
|
||||
for density in "${!NOTIF_SIZES[@]}"; do
|
||||
size=${NOTIF_SIZES[$density]}
|
||||
output_dir="$ANDROID_RES/drawable-$density"
|
||||
mkdir -p "$output_dir"
|
||||
generate_white_icon "$size" "$output_dir/ic_stat_notification.png"
|
||||
done
|
||||
|
||||
# Generate Monochrome Launcher Icons (108dp base)
|
||||
echo ""
|
||||
echo "🚀 Generating monochrome launcher icons (ic_launcher_monochrome.png)..."
|
||||
|
||||
# Monochrome launcher icon densities: 108dp base
|
||||
# mdpi=1x, hdpi=1.5x, xhdpi=2x, xxhdpi=3x, xxxhdpi=4x
|
||||
declare -A MONO_SIZES=(
|
||||
["mdpi"]=108
|
||||
["hdpi"]=162
|
||||
["xhdpi"]=216
|
||||
["xxhdpi"]=324
|
||||
["xxxhdpi"]=432
|
||||
)
|
||||
|
||||
for density in "${!MONO_SIZES[@]}"; do
|
||||
size=${MONO_SIZES[$density]}
|
||||
output_dir="$ANDROID_RES/mipmap-$density"
|
||||
mkdir -p "$output_dir"
|
||||
generate_white_icon "$size" "$output_dir/ic_launcher_monochrome.png"
|
||||
done
|
||||
|
||||
# Clean up
|
||||
rm -rf "$TEMP_DIR"
|
||||
|
||||
echo ""
|
||||
echo "✅ All icons generated successfully!"
|
||||
echo ""
|
||||
echo "Icon locations:"
|
||||
echo " • Notification icons: $ANDROID_RES/drawable-*/ic_stat_notification.png"
|
||||
echo " • Monochrome icons: $ANDROID_RES/mipmap-*/ic_launcher_monochrome.png"
|
||||
echo ""
|
||||
echo "To apply changes, rebuild the app: flutter clean && flutter build apk"
|
||||
@@ -9,6 +9,7 @@
|
||||
#include <hotkey_manager_windows/hotkey_manager_windows_plugin_c_api.h>
|
||||
#include <media_kit_libs_windows_video/media_kit_libs_windows_video_plugin_c_api.h>
|
||||
#include <media_kit_video/media_kit_video_plugin_c_api.h>
|
||||
#include <os_media_controls/os_media_controls_plugin_c_api.h>
|
||||
#include <screen_retriever_windows/screen_retriever_windows_plugin_c_api.h>
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
#include <volume_controller/volume_controller_plugin_c_api.h>
|
||||
@@ -21,6 +22,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
registry->GetRegistrarForPlugin("MediaKitLibsWindowsVideoPluginCApi"));
|
||||
MediaKitVideoPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("MediaKitVideoPluginCApi"));
|
||||
OsMediaControlsPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("OsMediaControlsPluginCApi"));
|
||||
ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi"));
|
||||
UrlLauncherWindowsRegisterWithRegistrar(
|
||||
|
||||
@@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
||||
hotkey_manager_windows
|
||||
media_kit_libs_windows_video
|
||||
media_kit_video
|
||||
os_media_controls
|
||||
screen_retriever_windows
|
||||
url_launcher_windows
|
||||
volume_controller
|
||||
|
||||