From d37195e01e35e9d2cedf5cbacb333127b7310b29 Mon Sep 17 00:00:00 2001 From: Jovan Date: Fri, 14 Nov 2025 23:27:21 +0800 Subject: [PATCH 01/29] add zh i18n --- lib/i18n/strings_zh.i18n..json | 370 +++++++++++++++++++++++++++++++++ 1 file changed, 370 insertions(+) create mode 100644 lib/i18n/strings_zh.i18n..json diff --git a/lib/i18n/strings_zh.i18n..json b/lib/i18n/strings_zh.i18n..json new file mode 100644 index 00000000..e19091a2 --- /dev/null +++ b/lib/i18n/strings_zh.i18n..json @@ -0,0 +1,370 @@ +{ + "app": { + "title": "Plezy", + "loading": "加载中..." + }, + "auth": { + "signInWithPlex": "使用 Plex 登录", + "showQRCode": "显示二维码", + "cancel": "取消", + "authenticate": "验证", + "retry": "重试", + "debugEnterToken": "调试:输入 Plex Token", + "plexTokenLabel": "Plex 授权令牌 (Auth Token)", + "plexTokenHint": "输入你的 Plex.tv 令牌", + "authenticationTimeout": "验证超时。请重试。", + "scanQRCodeInstruction": "请使用已登录 Plex 的设备扫描此二维码进行验证。", + "waitingForAuth": "等待验证中...\n请在你的浏览器中完成登录。" + }, + "common": { + "cancel": "取消", + "save": "保存", + "close": "关闭", + "clear": "清除", + "reset": "重置", + "later": "稍后", + "submit": "提交", + "confirm": "确认", + "retry": "重试", + "playNow": "立即播放", + "logout": "登出", + "online": "在线", + "offline": "离线", + "owned": "拥有", + "shared": "共享", + "current": "当前", + "unknown": "未知", + "refresh": "刷新", + "yes": "是", + "no": "否", + "server": "服务器" + }, + "screens": { + "licenses": "许可证", + "selectServer": "选择服务器", + "switchProfile": "切换配置文件", + "subtitleStyling": "字幕样式", + "search": "搜索", + "logs": "日志" + }, + "update": { + "available": "有可用更新", + "versionAvailable": "版本 ${version} 已发布", + "currentVersion": "当前版本: ${version}", + "skipVersion": "跳过此版本", + "viewRelease": "查看发布详情", + "latestVersion": "已经使用的是最新版本", + "checkFailed": "无法检查更新" + }, + "settings": { + "title": "设置", + "language": "语言", + "theme": "主题", + "appearance": "外观", + "videoPlayback": "视频播放", + "shufflePlay": "随机播放", + "advanced": "高级", + "useSeasonPostersDescription": "为剧集显示季海报而非剧集海报", + "showHeroSectionDescription": "在主屏幕上显示精选内容轮播区", + "secondsLabel": "秒", + "minutesLabel": "分钟", + "secondsShort": "秒", + "minutesShort": "分", + "durationHint": "输入时长 (${min}-${max})", + "systemTheme": "系统", + "systemThemeDescription": "跟随系统设置", + "lightTheme": "浅色", + "darkTheme": "深色", + "libraryDensity": "媒体库密度", + "compact": "紧凑", + "compactDescription": "卡片更小,显示更多项目", + "normal": "标准", + "normalDescription": "默认尺寸", + "comfortable": "舒适", + "comfortableDescription": "卡片更大,显示更少项目", + "viewMode": "视图模式", + "gridView": "网格视图", + "gridViewDescription": "以网格布局显示项目", + "listView": "列表视图", + "listViewDescription": "以列表布局显示项目", + "useSeasonPosters": "使用季海报", + "showHeroSection": "显示主要精选区", + "hardwareDecoding": "硬件解码", + "hardwareDecodingDescription": "如果可用,使用硬件加速", + "bufferSize": "缓冲区大小", + "bufferSizeMB": "${size}MB", + "subtitleStyling": "字幕样式", + "subtitleStylingDescription": "调整字幕外观", + "smallSkipDuration": "短跳过时长", + "largeSkipDuration": "长跳过时长", + "secondsUnit": "${seconds} 秒", + "defaultSleepTimer": "默认睡眠定时器", + "minutesUnit": "在 ${minutes} 分钟", + "unwatchedOnly": "仅未观看", + "unwatchedOnlyDescription": "随机队列中仅包含未观看的剧集", + "shuffleOrderNavigation": "随机顺序导航", + "shuffleOrderNavigationDescription": "下一集/上一集按钮遵循随机播放顺序", + "loopShuffleQueue": "循环随机队列", + "loopShuffleQueueDescription": "在队列结束时重新开始", + "videoPlayerControls": "视频播放器控制", + "keyboardShortcuts": "键盘快捷键", + "keyboardShortcutsDescription": "自定义键盘快捷键", + "debugLogging": "调试日志", + "debugLoggingDescription": "启用详细日志记录以便故障排除", + "viewLogs": "查看日志", + "viewLogsDescription": "查看应用程序日志", + "clearCache": "清除缓存", + "clearCacheDescription": "这将清除所有缓存的图片和数据。清除缓存后,应用程序加载内容可能会变慢。", + "clearCacheSuccess": "缓存清除成功", + "resetSettings": "重置设置", + "resetSettingsDescription": "这会将所有设置重置为其默认值。此操作无法撤销。", + "resetSettingsSuccess": "设置重置成功", + "shortcutsReset": "快捷键已重置为默认值", + "about": "关于", + "aboutDescription": "应用程序信息和许可证", + "updates": "更新", + "updateAvailable": "有可用更新", + "checkForUpdates": "检查更新", + "validationErrorEnterNumber": "请输入一个有效的数字", + "validationErrorDuration": "时长必须介于 ${min} 和 ${max} ${unit} 之间", + "shortcutAlreadyAssigned": "快捷键已被分配给 ${action}", + "shortcutUpdated": "快捷键已为 ${action} 更新" + }, + "search": { + "hint": "搜索电影、系列、音乐...", + "tryDifferentTerm": "尝试不同的搜索词", + "searchYourMedia": "搜索媒体", + "enterTitleActorOrKeyword": "输入标题、演员或关键词" + }, + "hotkeys": { + "setShortcutFor": "为 ${actionName} 设置快捷键", + "clearShortcut": "清除快捷键" + }, + "pinEntry": { + "enterPin": "输入 PIN", + "showPin": "显示 PIN", + "hidePin": "隐藏 PIN" + }, + "fileInfo": { + "title": "文件信息", + "video": "视频", + "audio": "音频", + "file": "文件", + "advanced": "高级", + "codec": "编解码器", + "resolution": "分辨率", + "bitrate": "比特率", + "frameRate": "帧率", + "aspectRatio": "宽高比", + "profile": "配置文件", + "bitDepth": "位深度", + "colorSpace": "色彩空间", + "colorRange": "色彩范围", + "colorPrimaries": "颜色原色", + "chromaSubsampling": "色度子采样", + "channels": "声道", + "path": "路径", + "size": "大小", + "container": "容器", + "duration": "时长", + "optimizedForStreaming": "已优化用于流媒体", + "has64bitOffsets": "64位偏移量" + }, + "mediaMenu": { + "markAsWatched": "标记为已观看", + "markAsUnwatched": "标记为未观看", + "removeFromContinueWatching": "从继续观看中移除", + "goToSeries": "转到系列", + "goToSeason": "转到季", + "shufflePlay": "随机播放", + "fileInfo": "文件信息" + }, + "tooltips": { + "shufflePlay": "随机播放", + "markAsWatched": "标记为已观看", + "markAsUnwatched": "标记为未观看" + }, + "videoControls": { + "audioLabel": "音频", + "subtitlesLabel": "字幕", + "resetToZero": "重置为 0ms", + "addTime": "+${amount}${unit}", + "minusTime": "-${amount}${unit}", + "playsLater": "${label} 播放较晚", + "playsEarlier": "${label} 播放较早", + "noOffset": "无偏移", + "letterbox": "信箱模式(Letterbox)", + "fillScreen": "填充屏幕", + "stretch": "拉伸", + "lockRotation": "锁定旋转", + "unlockRotation": "解锁旋转" + }, + "userStatus": { + "admin": "管理员", + "restricted": "受限", + "protected": "受保护" + }, + "messages": { + "markedAsWatched": "已标记为已观看", + "markedAsUnwatched": "已标记为未观看", + "removedFromContinueWatching": "已从继续观看中移除", + "errorLoading": "错误: ${error}", + "fileInfoNotAvailable": "文件信息不可用", + "errorLoadingFileInfo": "加载文件信息时出错: ${error}", + "errorLoadingSeries": "加载系列时出错", + "errorLoadingSeason": "加载季时出错", + "musicNotSupported": "尚不支持播放音乐", + "logsCleared": "日志已清除", + "logsCopied": "日志已复制到剪贴板", + "noLogsAvailable": "没有可用日志", + "libraryScanning": "正在扫描 “${title}”...", + "libraryScanStarted": "已开始扫描 “${title}” 媒体库", + "libraryScanFailed": "无法扫描媒体库: ${error}", + "metadataRefreshing": "正在刷新 “${title}” 的元数据...", + "metadataRefreshStarted": "已开始刷新 “${title}” 的元数据", + "metadataRefreshFailed": "无法刷新元数据: ${error}", + "noPlexToken": "未找到 Plex 令牌。请重新登录。", + "logoutConfirm": "你确定要登出吗?", + "noSeasonsFound": "未找到季", + "noEpisodesFound": "在第一季中未找到剧集", + "noEpisodesFoundGeneral": "未找到剧集", + "noResultsFound": "未找到结果", + "sleepTimerSet": "睡眠定时器已设置为 ${label}", + "failedToSwitchProfile": "无法切换到 ${displayName}" + }, + "profile": { + "noUsersAvailable": "没有可用用户" + }, + "subtitlingStyling": { + "stylingOptions": "样式选项", + "fontSize": "字号", + "textColor": "文本颜色", + "borderSize": "边框大小", + "borderColor": "边框颜色", + "backgroundOpacity": "背景不透明度", + "backgroundColor": "背景颜色" + }, + "dialog": { + "confirmAction": "确认操作", + "areYouSure": "确定要执行此操作吗?", + "cancel": "取消", + "playNow": "立即播放" + }, + "discover": { + "title": "发现", + "switchProfile": "切换配置文件", + "switchServer": "切换服务器", + "logout": "登出", + "noContentAvailable": "没有可用内容", + "addMediaToLibraries": "请向的媒体库添加一些媒体", + "continueWatching": "继续观看", + "recentlyAdded": "最近添加", + "play": "播放", + "resume": "继续", + "playEpisode": "播放 S${season}, E${episode}", + "resumeEpisode": "继续 S${season}, E${episode}", + "pause": "暂停", + "overview": "概述", + "cast": "演员表", + "episodeCount": "${count} 集", + "watchedProgress": "已观看 ${watched}/${total} 集", + "movie": "电影", + "tvShow": "电视剧", + "minutesLeft": "剩余 ${minutes} 分钟" + }, + "errors": { + "searchFailed": "搜索失败: ${error}", + "connectionTimeout": "加载 ${context} 时连接超时", + "connectionFailed": "无法连接到 Plex 服务器", + "failedToLoad": "无法加载 ${context}: ${error}", + "noClientAvailable": "没有可用客户端", + "authenticationFailed": "验证失败: ${error}", + "couldNotLaunchUrl": "无法打开授权 URL", + "pleaseEnterToken": "请输入一个令牌", + "invalidToken": "令牌无效", + "failedToVerifyToken": "无法验证令牌: ${error}", + "failedToSwitchProfile": "无法切换到 ${displayName}", + "connectionFailedGeneric": "连接失败" + }, + "libraries": { + "title": "媒体库", + "scanLibraryFiles": "扫描媒体库文件", + "scanLibrary": "扫描媒体库", + "analyze": "分析", + "analyzeLibrary": "分析媒体库", + "refreshMetadata": "刷新元数据", + "emptyTrash": "清空回收站", + "emptyingTrash": "正在清空 “${title}” 的回收站...", + "trashEmptied": "已清空 “${title}” 的回收站", + "failedToEmptyTrash": "无法清空回收站: ${error}", + "analyzing": "正在分析 “${title}”...", + "analysisStarted": "已开始分析 “${title}”", + "failedToAnalyze": "无法分析媒体库: ${error}", + "noLibrariesFound": "未找到媒体库", + "thisLibraryIsEmpty": "此媒体库为空", + "all": "全部", + "clearAll": "全部清除", + "scanLibraryConfirm": "确定要扫描 “${title}” 吗?", + "analyzeLibraryConfirm": "确定要分析 “${title}” 吗?", + "refreshMetadataConfirm": "确定要刷新 “${title}” 的元数据吗?", + "emptyTrashConfirm": "确定要清空 “${title}” 的回收站吗?", + "manageLibraries": "管理媒体库", + "sort": "排序", + "sortBy": "排序依据", + "filters": "筛选器", + "loadingLibraryWithCount": "正在加载媒体库... (已加载 ${count} 个项目)", + "confirmActionMessage": "确定要执行此操作吗?", + "showLibrary": "显示媒体库", + "hideLibrary": "隐藏媒体库", + "libraryOptions": "媒体库选项" + }, + "about": { + "title": "关于", + "openSourceLicenses": "开源许可证", + "versionLabel": "版本 ${version}", + "appDescription": "一款精美的 Flutter Plex 客户端", + "viewLicensesDescription": "查看第三方库的许可证" + }, + "serverSelection": { + "connectingToServer": "正在连接服务器...", + "serverDebugCopied": "服务器调试数据已复制到剪贴板", + "copyDebugData": "复制调试数据", + "noServersFound": "未找到服务器", + "malformedServerData": "找到 ${count} 个数据格式错误的服务器。没有可用的有效服务器。", + "incompleteServerInfo": "某些服务器信息不完整,已被跳过。请检查你的 Plex.tv 账户。", + "incompleteConnectionInfo": "服务器连接信息不完整。请重试。", + "malformedServerInfo": "服务器信息格式错误: ${message}", + "networkConnectionFailed": "网络连接失败。请检查你的网络连接并重试。", + "authenticationFailed": "验证失败。请重新登录。", + "plexServiceUnavailable": "Plex 服务不可用。请稍后重试。", + "failedToLoadServers": "无法加载服务器: ${error}" + }, + "hubDetail": { + "title": "标题", + "releaseYear": "发行年份", + "dateAdded": "添加日期", + "rating": "评分", + "noItemsFound": "未找到项目" + }, + "logs": { + "title": "日志", + "clearLogs": "清除日志", + "copyLogs": "复制日志", + "exportLogs": "导出日志", + "noLogsToShow": "没有可显示的日志", + "error": "错误:", + "stackTrace": "堆栈跟踪 (Stack Trace):" + }, + "licenses": { + "relatedPackages": "相关软件包", + "license": "许可证", + "licenseNumber": "许可证 ${number}", + "licensesCount": "${count} 个许可证" + }, + "navigation": { + "home": "主页", + "search": "搜索", + "libraries": "媒体库", + "settings": "设置" + } +} From 6c41e0921484c97123b126ff90ddb243ab3a326c Mon Sep 17 00:00:00 2001 From: Jovan Date: Fri, 14 Nov 2025 23:32:17 +0800 Subject: [PATCH 02/29] i18n(zh): minor fixes --- lib/i18n/strings_zh.i18n..json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/i18n/strings_zh.i18n..json b/lib/i18n/strings_zh.i18n..json index e19091a2..668d4197 100644 --- a/lib/i18n/strings_zh.i18n..json +++ b/lib/i18n/strings_zh.i18n..json @@ -53,7 +53,7 @@ "currentVersion": "当前版本: ${version}", "skipVersion": "跳过此版本", "viewRelease": "查看发布详情", - "latestVersion": "已经使用的是最新版本", + "latestVersion": "已安装的版本是可用的最新版本", "checkFailed": "无法检查更新" }, "settings": { From 0da4b11f17b83bcf0bca96670812d78ab04be02e Mon Sep 17 00:00:00 2001 From: Jovan Date: Fri, 14 Nov 2025 23:34:42 +0800 Subject: [PATCH 03/29] rename file --- lib/i18n/{strings_zh.i18n..json => strings_zh.i18n.json} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename lib/i18n/{strings_zh.i18n..json => strings_zh.i18n.json} (100%) diff --git a/lib/i18n/strings_zh.i18n..json b/lib/i18n/strings_zh.i18n.json similarity index 100% rename from lib/i18n/strings_zh.i18n..json rename to lib/i18n/strings_zh.i18n.json From 415cb90af2877060fbddd8f5bf99b90ec6f39de8 Mon Sep 17 00:00:00 2001 From: Jovan Date: Fri, 14 Nov 2025 23:37:25 +0800 Subject: [PATCH 04/29] fix --- lib/i18n/strings_zh.i18n.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/i18n/strings_zh.i18n.json b/lib/i18n/strings_zh.i18n.json index 668d4197..6fb78497 100644 --- a/lib/i18n/strings_zh.i18n.json +++ b/lib/i18n/strings_zh.i18n.json @@ -68,8 +68,8 @@ "showHeroSectionDescription": "在主屏幕上显示精选内容轮播区", "secondsLabel": "秒", "minutesLabel": "分钟", - "secondsShort": "秒", - "minutesShort": "分", + "secondsShort": "s", + "minutesShort": "m", "durationHint": "输入时长 (${min}-${max})", "systemTheme": "系统", "systemThemeDescription": "跟随系统设置", From 0b9998ddffb3090561abd4594857fb0bc5f5f122 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 14 Nov 2025 19:26:04 +0100 Subject: [PATCH 05/29] fix: don't crop subtitles --- lib/screens/video_player_screen.dart | 253 +++++++++++++++++++++++++-- macos/Podfile.lock | 5 +- pubspec.lock | 51 +++--- pubspec.yaml | 24 ++- 4 files changed, 291 insertions(+), 42 deletions(-) diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 7d5a5826..2dd6e7ff 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -68,6 +68,11 @@ class VideoPlayerScreenState extends State { int _boxFitMode = 0; bool _isPinching = false; // Track if a pinch gesture is occurring + // Video cropping state for fill screen mode + Size? _playerSize; + Size? _videoSize; + Timer? _resizeDebounceTimer; + @override void initState() { super.initState(); @@ -108,6 +113,11 @@ class VideoPlayerScreenState extends State { appLogger.w('Failed to determine device type', error: e); _isPhone = false; // Default to tablet/desktop (all orientations) } + + // Update video filter when dependencies change (orientation, screen size, etc.) + WidgetsBinding.instance.addPostFrameCallback((_) { + _debouncedUpdateVideoFilter(); + }); } Future _initializePlayer() async { @@ -137,6 +147,7 @@ class VideoPlayerScreenState extends State { 'sub-border-color': settingsService.getSubtitleBorderColor(), 'sub-back-color': '#${(settingsService.getSubtitleBackgroundOpacity() * 255 / 100).toInt().toRadixString(16).padLeft(2, '0').toUpperCase()}${settingsService.getSubtitleBackgroundColor().replaceFirst('#', '')}', + 'sub-ass-override': 'yes', }, ), ); @@ -316,6 +327,8 @@ class VideoPlayerScreenState extends State { setState(() { _availableVersions = playbackData.availableVersions; }); + // Update video filter once dimensions are available + _updateVideoFilter(); } // Build list of external subtitle tracks for media_kit @@ -440,6 +453,7 @@ class VideoPlayerScreenState extends State { setState(() { _boxFitMode = (_boxFitMode + 1) % 3; }); + _updateVideoFilter(); } /// Toggle between contain and cover modes only (for pinch gesture) @@ -447,6 +461,7 @@ class VideoPlayerScreenState extends State { setState(() { _boxFitMode = _boxFitMode == 0 ? 1 : 0; }); + _updateVideoFilter(); } /// Get current BoxFit based on mode @@ -463,11 +478,183 @@ class VideoPlayerScreenState extends State { } } + /// Calculate crop parameters to match what BoxFit.cover will show + Map? _calculateCropParameters() { + if (_boxFitMode != 1 || _playerSize == null || _videoSize == null) { + return null; + } + + final playerAspectRatio = _playerSize!.width / _playerSize!.height; + final videoAspectRatio = _videoSize!.width / _videoSize!.height; + + // No cropping needed if aspect ratios are very similar + if ((playerAspectRatio - videoAspectRatio).abs() < 0.01) { + return null; + } + + int cropWidth, cropHeight, cropX, cropY; + + // BoxFit.cover scales the video to fill the container, cropping the excess + // We need to crop the video to match what will actually be visible + if (videoAspectRatio > playerAspectRatio) { + // Video is wider than player - BoxFit.cover will crop horizontally + // Scale video height to match player height, then crop the width + final scale = _playerSize!.height / _videoSize!.height; + + cropHeight = _videoSize!.height.toInt(); + cropWidth = (_playerSize!.width / scale).toInt(); + cropX = ((_videoSize!.width - cropWidth) / 2).toInt(); + cropY = 0; + } else { + // Video is taller than player - BoxFit.cover will crop vertically + // Scale video width to match player width, then crop the height + final scale = _playerSize!.width / _videoSize!.width; + + cropWidth = _videoSize!.width.toInt(); + cropHeight = (_playerSize!.height / scale).toInt(); + cropX = 0; + cropY = ((_videoSize!.height - cropHeight) / 2).toInt(); + } + + // Calculate subtitle margins to prevent text subtitles from appearing in cropped areas + // MPV subtitle coordinates use a normalized system where video height = 720 + const double subCoordinateHeight = 720.0; + const double baseMarginY = + 40.0; // Base margin from bottom edge (subtitle coordinates) + const double baseMarginX = + 20.0; // Base margin from sides (subtitle coordinates) + + double subMarginX = baseMarginX; + double subMarginY = baseMarginY; + double subScale = 1.0; // Default scale + + if (videoAspectRatio > playerAspectRatio) { + // Horizontal crop - need additional horizontal margins and scaling + // Convert pixel margin to subtitle coordinate system + final subCoordinateWidth = subCoordinateHeight * videoAspectRatio; + final cropMarginX = (cropX / _videoSize!.width) * subCoordinateWidth; + + // Calculate scale factor first + subScale = cropWidth / _videoSize!.width; + + // Apply margin accounting for scaling (scaled margins are effectively larger) + subMarginX = (baseMarginX + cropMarginX) / subScale; + } else { + // Vertical crop - need additional vertical margins and scaling + // Convert pixel margin to subtitle coordinate system + final cropMarginY = (cropY / _videoSize!.height) * subCoordinateHeight; + + // Calculate scale factor first + subScale = cropHeight / _videoSize!.height; + + // Apply margin accounting for scaling (scaled margins are effectively larger) + subMarginY = (baseMarginY + cropMarginY) / subScale; + } + + return { + 'width': cropWidth, + 'height': cropHeight, + 'x': cropX, + 'y': cropY, + 'subMarginX': subMarginX.round(), + 'subMarginY': subMarginY.round(), + 'subScale': subScale, + }; + } + + /// Get video dimensions from the currently selected media version + Size? _getCurrentVideoSize() { + if (_availableVersions.isEmpty || + widget.selectedMediaIndex >= _availableVersions.length) { + return null; + } + + final currentVersion = _availableVersions[widget.selectedMediaIndex]; + if (currentVersion.width != null && currentVersion.height != null) { + return Size( + currentVersion.width!.toDouble(), + currentVersion.height!.toDouble(), + ); + } + + return null; + } + + /// Update the video filter based on current crop mode + void _updateVideoFilter() async { + if (player == null) return; + + try { + final nativePlayer = player!.platform as dynamic; + + if (_boxFitMode == 1) { + // Fill screen mode - apply crop filter + _videoSize = _getCurrentVideoSize(); + final cropParams = _calculateCropParameters(); + + if (cropParams != null) { + final cropFilter = + 'crop=${cropParams['width']}:${cropParams['height']}:${cropParams['x']}:${cropParams['y']}'; + appLogger.d( + 'Applying video filter: $cropFilter (player: $_playerSize, video: $_videoSize)', + ); + + // Apply crop filter + await nativePlayer.setProperty('vf', cropFilter); + + // Apply subtitle margins and scaling to compensate for crop zoom + final subMarginX = cropParams['subMarginX']!; + final subMarginY = cropParams['subMarginY']!; + final subScale = cropParams['subScale']!; + + appLogger.d( + 'Applying subtitle properties - margins: x=$subMarginX, y=$subMarginY, scale=$subScale', + ); + + await nativePlayer.setProperty('sub-margin-x', subMarginX.toString()); + await nativePlayer.setProperty('sub-margin-y', subMarginY.toString()); + await nativePlayer.setProperty('sub-scale', subScale.toString()); + } else { + // Clear filter but apply base margins if no cropping needed + appLogger.d( + 'Clearing video filter - aspect ratios similar, applying base margins (player: $_playerSize, video: $_videoSize)', + ); + await nativePlayer.setProperty('vf', ''); + await nativePlayer.setProperty('sub-margin-x', '20'); // Base margin + await nativePlayer.setProperty('sub-margin-y', '40'); // Base margin + await nativePlayer.setProperty('sub-scale', '1.0'); // Reset scale + } + } else { + // Other modes - clear video filter but apply base margins + appLogger.d( + 'Clearing video filter, applying base margins - BoxFit mode $_boxFitMode', + ); + await nativePlayer.setProperty('vf', ''); + await nativePlayer.setProperty('sub-margin-x', '20'); // Base margin + await nativePlayer.setProperty('sub-margin-y', '40'); // Base margin + await nativePlayer.setProperty('sub-scale', '1.0'); // Reset scale + } + } catch (e) { + appLogger.w('Failed to update video filter', error: e); + } + } + + /// Debounced version of _updateVideoFilter for resize events + void _debouncedUpdateVideoFilter() { + _resizeDebounceTimer?.cancel(); + _resizeDebounceTimer = Timer(const Duration(milliseconds: 50), () { + _updateVideoFilter(); + }); + } + @override void dispose() { // Stop progress tracking _progressTimer?.cancel(); + // Cancel debounce timer + _resizeDebounceTimer?.cancel(); + // Cancel stream subscriptions _playingSubscription?.cancel(); _completedSubscription?.cancel(); @@ -482,6 +669,19 @@ class VideoPlayerScreenState extends State { // Send final stopped state _sendProgress('stopped'); + // Clear video filter and reset subtitle margins before disposing player + try { + if (player != null) { + final nativePlayer = player!.platform as dynamic; + nativePlayer.setProperty('vf', ''); + nativePlayer.setProperty('sub-margin-x', '0'); + nativePlayer.setProperty('sub-margin-y', '0'); + nativePlayer.setProperty('sub-scale', '1.0'); + } + } catch (e) { + // Ignore errors during cleanup + } + // Restore system UI and orientation preferences (skip if navigating to another video) if (!_isReplacingWithVideo) { OrientationHelper.restoreSystemUI(); @@ -1378,19 +1578,46 @@ class VideoPlayerScreenState extends State { children: [ // Video player Center( - child: Video( - controller: controller!, - fit: _getCurrentBoxFit, - controls: (state) => plexVideoControlsBuilder( - player!, - widget.metadata, - onNext: _nextEpisode != null ? _playNext : null, - onPrevious: _previousEpisode != null ? _playPrevious : null, - availableVersions: _availableVersions, - selectedMediaIndex: widget.selectedMediaIndex, - boxFitMode: _boxFitMode, - onCycleBoxFitMode: _cycleBoxFitMode, - ), + child: LayoutBuilder( + builder: (context, constraints) { + // Update player size when layout changes + final newSize = Size( + constraints.maxWidth, + constraints.maxHeight, + ); + + // Check if size actually changed to avoid unnecessary updates + if (_playerSize == null || + (_playerSize!.width - newSize.width).abs() > 0.1 || + (_playerSize!.height - newSize.height).abs() > 0.1) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + _playerSize = newSize; + }); + // Use debounced update for resize events + _debouncedUpdateVideoFilter(); + } + }); + } + + return Video( + controller: controller!, + fit: _getCurrentBoxFit, + controls: (state) => plexVideoControlsBuilder( + player!, + widget.metadata, + onNext: _nextEpisode != null ? _playNext : null, + onPrevious: _previousEpisode != null + ? _playPrevious + : null, + availableVersions: _availableVersions, + selectedMediaIndex: widget.selectedMediaIndex, + boxFitMode: _boxFitMode, + onCycleBoxFitMode: _cycleBoxFitMode, + ), + ); + }, ), ), // Play Next Dialog diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 528496e2..3035b5b1 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -10,6 +10,7 @@ PODS: - FlutterMacOS - media_kit_video (0.0.1): - FlutterMacOS + - media_kit_libs_macos_video - os_media_controls (0.0.1): - FlutterMacOS - package_info_plus (0.0.1): @@ -92,8 +93,8 @@ SPEC CHECKSUMS: HotKey: 400beb7caa29054ea8d864c96f5ba7e5b4852277 hotkey_manager_macos: a4317849af96d2430fa89944d3c58977ca089fbe macos_window_utils: 23f54331a0fd51eea9e0ed347253bf48fd379d1d - media_kit_libs_macos_video: 85a23e549b5f480e72cae3e5634b5514bc692f65 - media_kit_video: fa6564e3799a0a28bff39442334817088b7ca758 + media_kit_libs_macos_video: 69caff44badac986515a602bae1eb7f988a29f93 + media_kit_video: 71dcfc00fc7d2e7dbe43e60b5eb505f15ab6a88e os_media_controls: c07c04c4afdf59dda0a3f398457a46823c4ce0ed package_info_plus: f0052d280d17aa382b932f399edf32507174e870 path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 diff --git a/pubspec.lock b/pubspec.lock index d49b164d..328c7b93 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -540,26 +540,26 @@ packages: dependency: "direct main" description: path: media_kit - ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5" - resolved-ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5" + ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" + resolved-ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" url: "https://github.com/edde746/media-kit" source: git version: "1.2.1" media_kit_libs_android_video: - dependency: transitive + dependency: "direct overridden" description: path: "libs/android/media_kit_libs_android_video" - ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5" - resolved-ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5" + ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" + resolved-ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" url: "https://github.com/edde746/media-kit" source: git version: "1.3.9" media_kit_libs_ios_video: - dependency: transitive + dependency: "direct overridden" description: path: "libs/ios/media_kit_libs_ios_video" - ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5" - resolved-ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5" + ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" + resolved-ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" url: "https://github.com/edde746/media-kit" source: git version: "1.1.4" @@ -567,17 +567,17 @@ packages: dependency: transitive description: path: "libs/linux/media_kit_libs_linux" - ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5" - resolved-ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5" + ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" + resolved-ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" url: "https://github.com/edde746/media-kit" source: git version: "1.2.1" media_kit_libs_macos_video: - dependency: transitive + dependency: "direct overridden" description: path: "libs/macos/media_kit_libs_macos_video" - ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5" - resolved-ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5" + ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" + resolved-ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" url: "https://github.com/edde746/media-kit" source: git version: "1.1.5" @@ -585,8 +585,8 @@ packages: dependency: "direct main" description: path: "libs/universal/media_kit_libs_video" - ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5" - resolved-ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5" + ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" + resolved-ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" url: "https://github.com/edde746/media-kit" source: git version: "1.0.7" @@ -594,27 +594,28 @@ packages: dependency: transitive description: path: "libs/windows/media_kit_libs_windows_video" - ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5" - resolved-ref: "9782771486c0356b48c2e31e47365d1ed0b7fcb5" + ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" + resolved-ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" url: "https://github.com/edde746/media-kit" source: git version: "1.0.12" media_kit_video: dependency: "direct main" description: - name: media_kit_video - sha256: "813858c3fe84eb46679eb698695f60665e2bfbef757766fac4d2e683f926e15a" - url: "https://pub.dev" - source: hosted + path: media_kit_video + ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" + resolved-ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" + url: "https://github.com/edde746/media-kit" + source: git version: "1.3.1" meta: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.16.0" mime: dependency: transitive description: @@ -1097,10 +1098,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.6" timing: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 6f952caa..74002982 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -37,13 +37,33 @@ dependency_overrides: media_kit: git: url: https://github.com/edde746/media-kit - ref: 9782771486c0356b48c2e31e47365d1ed0b7fcb5 + ref: 6bf03092e10e2f6ce19766ed290c0e6610be76f1 path: media_kit + media_kit_video: + git: + url: https://github.com/edde746/media-kit + ref: 6bf03092e10e2f6ce19766ed290c0e6610be76f1 + path: media_kit_video media_kit_libs_video: git: url: https://github.com/edde746/media-kit - ref: 9782771486c0356b48c2e31e47365d1ed0b7fcb5 + ref: 6bf03092e10e2f6ce19766ed290c0e6610be76f1 path: libs/universal/media_kit_libs_video + media_kit_libs_macos_video: + git: + url: https://github.com/edde746/media-kit + ref: 6bf03092e10e2f6ce19766ed290c0e6610be76f1 + path: libs/macos/media_kit_libs_macos_video + media_kit_libs_ios_video: + git: + url: https://github.com/edde746/media-kit + ref: 6bf03092e10e2f6ce19766ed290c0e6610be76f1 + path: libs/ios/media_kit_libs_ios_video + media_kit_libs_android_video: + git: + url: https://github.com/edde746/media-kit + ref: 6bf03092e10e2f6ce19766ed290c0e6610be76f1 + path: libs/android/media_kit_libs_android_video dev_dependencies: flutter_test: From 173ab5900777e085defe5e3e7329fba5b764a1c6 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 14 Nov 2025 22:02:49 +0100 Subject: [PATCH 06/29] refactor: remove unnecessary background mode --- ios/Podfile.lock | 3 ++- ios/Runner/Info.plist | 4 ---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index b352e747..70cb3503 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -4,6 +4,7 @@ PODS: - Flutter - media_kit_video (0.0.1): - Flutter + - media_kit_libs_ios_video - os_media_controls (0.0.1): - Flutter - package_info_plus (0.4.5): @@ -64,7 +65,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 media_kit_libs_ios_video: 5a18affdb97d1f5d466dc79988b13eff6c5e2854 - media_kit_video: 1746e198cb697d1ffb734b1d05ec429d1fcd1474 + media_kit_video: 6235abf1d299037d23692ad47119b89e05346b23 os_media_controls: 86dceab6245a5325af90fc0fdebe243c42d789b4 package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 03b3c3e7..0fc3e407 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -39,10 +39,6 @@ This app needs to connect to your Plex Media Server on your local network. UIApplicationSupportsIndirectInputEvents - UIBackgroundModes - - audio - UILaunchStoryboardName LaunchScreen UIMainStoryboardFile From a618218828e0b2fca331a81af80720a4a3ebffbd Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 14 Nov 2025 22:27:12 +0100 Subject: [PATCH 07/29] feat: respect per media language preference --- lib/client/plex_client.dart | 34 +++ lib/i18n/strings.g.dart | 20 +- lib/i18n/strings.i18n.json | 2 + lib/i18n/strings_it.i18n.json | 2 + lib/i18n/strings_nl.i18n.json | 2 + lib/i18n/strings_sv.i18n.json | 2 + lib/models/plex_metadata.dart | 8 + lib/models/plex_metadata.g.dart | 4 + lib/screens/settings_screen.dart | 14 ++ lib/screens/video_player_screen.dart | 207 ++++++++++++++++-- lib/services/settings_service.dart | 14 ++ lib/services/track_selection_service.dart | 117 ++++++---- .../sheets/audio_track_sheet.dart | 13 +- .../sheets/subtitle_track_sheet.dart | 18 +- .../video_controls/video_controls.dart | 21 +- 15 files changed, 407 insertions(+), 71 deletions(-) diff --git a/lib/client/plex_client.dart b/lib/client/plex_client.dart index ebce3753..83a6a930 100644 --- a/lib/client/plex_client.dart +++ b/lib/client/plex_client.dart @@ -302,6 +302,40 @@ class PlexClient { : null; } + /// Set per-media language preferences (audio and subtitle) + /// For TV shows, use grandparentRatingKey to set preference for the entire series + /// For movies, use the movie's ratingKey + Future setMetadataPreferences( + String ratingKey, { + String? audioLanguage, + String? subtitleLanguage, + }) async { + try { + final queryParams = {}; + if (audioLanguage != null) { + queryParams['audioLanguage'] = audioLanguage; + } + if (subtitleLanguage != null) { + queryParams['subtitleLanguage'] = subtitleLanguage; + } + + // If no preferences to set, return early + if (queryParams.isEmpty) { + return true; + } + + final response = await _dio.put( + '/library/metadata/$ratingKey/prefs', + queryParameters: queryParams, + ); + + return response.statusCode == 200; + } catch (e) { + appLogger.e('Failed to set metadata preferences', error: e); + return false; + } + } + /// Search across all libraries using the hub search endpoint /// Only returns movies and shows, filtering out seasons and episodes Future> search(String query, {int limit = 10}) async { diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 6d055508..3ce44476 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 4 -/// Strings: 1256 (314 per locale) +/// Strings: 1264 (316 per locale) /// -/// Built on 2025-11-13 at 21:35 UTC +/// Built on 2025-11-14 at 21:16 UTC // coverage:ignore-file // ignore_for_file: type=lint @@ -321,6 +321,8 @@ class _StringsSettingsEn { String secondsUnit({required Object seconds}) => '${seconds} seconds'; String get defaultSleepTimer => 'Default Sleep Timer'; String minutesUnit({required Object minutes}) => '${minutes} minutes'; + String get rememberTrackSelections => 'Remember track selections per show/movie'; + String get rememberTrackSelectionsDescription => 'Automatically save audio and subtitle language preferences when you change tracks during playback'; String get unwatchedOnly => 'Unwatched Only'; String get unwatchedOnlyDescription => 'Only include unwatched episodes in shuffle queue'; String get shuffleOrderNavigation => 'Shuffle Order Navigation'; @@ -931,6 +933,8 @@ class _StringsSettingsIt implements _StringsSettingsEn { @override String secondsUnit({required Object seconds}) => '${seconds} secondi'; @override String get defaultSleepTimer => 'Timer spegnimento predefinito'; @override String minutesUnit({required Object minutes}) => '${minutes} minuti'; + @override String get rememberTrackSelections => 'Ricorda selezioni tracce per serie/film'; + @override String get rememberTrackSelectionsDescription => 'Salva automaticamente le preferenze delle lingue audio e sottotitoli quando cambi tracce durante la riproduzione'; @override String get unwatchedOnly => 'Solo non guardati'; @override String get unwatchedOnlyDescription => 'Includi solo gli episodi non guardati nella coda di riproduzione casuale'; @override String get shuffleOrderNavigation => 'Navigazione in ordine casuale'; @@ -1541,6 +1545,8 @@ class _StringsSettingsNl implements _StringsSettingsEn { @override String secondsUnit({required Object seconds}) => '${seconds} seconden'; @override String get defaultSleepTimer => 'Standaard slaap timer'; @override String minutesUnit({required Object minutes}) => 'bij ${minutes} minuten'; + @override String get rememberTrackSelections => 'Onthoud track selecties per serie/film'; + @override String get rememberTrackSelectionsDescription => 'Bewaar automatisch audio- en ondertiteltaalvoorkeuren wanneer je tracks wijzigt tijdens afspelen'; @override String get unwatchedOnly => 'Alleen ongekeken'; @override String get unwatchedOnlyDescription => 'Alleen ongekeken afleveringen opnemen in willekeurige wachtrij'; @override String get shuffleOrderNavigation => 'Willekeurige volgorde navigatie'; @@ -2151,6 +2157,8 @@ class _StringsSettingsSv implements _StringsSettingsEn { @override String secondsUnit({required Object seconds}) => '${seconds} sekunder'; @override String get defaultSleepTimer => 'Standard sovtimer'; @override String minutesUnit({required Object minutes}) => '${minutes} minuter'; + @override String get rememberTrackSelections => 'Kom ihåg spårval per serie/film'; + @override String get rememberTrackSelectionsDescription => 'Spara automatiskt ljud- och undertextspråkpreferenser när du ändrar spår under uppspelning'; @override String get unwatchedOnly => 'Endast osedda'; @override String get unwatchedOnlyDescription => 'Inkludera endast osedda avsnitt i blandningskön'; @override String get shuffleOrderNavigation => 'Blandningsordning-navigation'; @@ -2662,6 +2670,8 @@ extension on Translations { case 'settings.secondsUnit': return ({required Object seconds}) => '${seconds} seconds'; case 'settings.defaultSleepTimer': return 'Default Sleep Timer'; case 'settings.minutesUnit': return ({required Object minutes}) => '${minutes} minutes'; + case 'settings.rememberTrackSelections': return 'Remember track selections per show/movie'; + case 'settings.rememberTrackSelectionsDescription': return 'Automatically save audio and subtitle language preferences when you change tracks during playback'; case 'settings.unwatchedOnly': return 'Unwatched Only'; case 'settings.unwatchedOnlyDescription': return 'Only include unwatched episodes in shuffle queue'; case 'settings.shuffleOrderNavigation': return 'Shuffle Order Navigation'; @@ -2984,6 +2994,8 @@ extension on _StringsIt { case 'settings.secondsUnit': return ({required Object seconds}) => '${seconds} secondi'; case 'settings.defaultSleepTimer': return 'Timer spegnimento predefinito'; case 'settings.minutesUnit': return ({required Object minutes}) => '${minutes} minuti'; + case 'settings.rememberTrackSelections': return 'Ricorda selezioni tracce per serie/film'; + case 'settings.rememberTrackSelectionsDescription': return 'Salva automaticamente le preferenze delle lingue audio e sottotitoli quando cambi tracce durante la riproduzione'; case 'settings.unwatchedOnly': return 'Solo non guardati'; case 'settings.unwatchedOnlyDescription': return 'Includi solo gli episodi non guardati nella coda di riproduzione casuale'; case 'settings.shuffleOrderNavigation': return 'Navigazione in ordine casuale'; @@ -3306,6 +3318,8 @@ extension on _StringsNl { case 'settings.secondsUnit': return ({required Object seconds}) => '${seconds} seconden'; case 'settings.defaultSleepTimer': return 'Standaard slaap timer'; case 'settings.minutesUnit': return ({required Object minutes}) => 'bij ${minutes} minuten'; + case 'settings.rememberTrackSelections': return 'Onthoud track selecties per serie/film'; + case 'settings.rememberTrackSelectionsDescription': return 'Bewaar automatisch audio- en ondertiteltaalvoorkeuren wanneer je tracks wijzigt tijdens afspelen'; case 'settings.unwatchedOnly': return 'Alleen ongekeken'; case 'settings.unwatchedOnlyDescription': return 'Alleen ongekeken afleveringen opnemen in willekeurige wachtrij'; case 'settings.shuffleOrderNavigation': return 'Willekeurige volgorde navigatie'; @@ -3628,6 +3642,8 @@ extension on _StringsSv { case 'settings.secondsUnit': return ({required Object seconds}) => '${seconds} sekunder'; case 'settings.defaultSleepTimer': return 'Standard sovtimer'; case 'settings.minutesUnit': return ({required Object minutes}) => '${minutes} minuter'; + case 'settings.rememberTrackSelections': return 'Kom ihåg spårval per serie/film'; + case 'settings.rememberTrackSelectionsDescription': return 'Spara automatiskt ljud- och undertextspråkpreferenser när du ändrar spår under uppspelning'; case 'settings.unwatchedOnly': return 'Endast osedda'; case 'settings.unwatchedOnlyDescription': return 'Inkludera endast osedda avsnitt i blandningskön'; case 'settings.shuffleOrderNavigation': return 'Blandningsordning-navigation'; diff --git a/lib/i18n/strings.i18n.json b/lib/i18n/strings.i18n.json index 9a5182fa..2d250cbd 100644 --- a/lib/i18n/strings.i18n.json +++ b/lib/i18n/strings.i18n.json @@ -100,6 +100,8 @@ "secondsUnit": "${seconds} seconds", "defaultSleepTimer": "Default Sleep Timer", "minutesUnit": "${minutes} minutes", + "rememberTrackSelections": "Remember track selections per show/movie", + "rememberTrackSelectionsDescription": "Automatically save audio and subtitle language preferences when you change tracks during playback", "unwatchedOnly": "Unwatched Only", "unwatchedOnlyDescription": "Only include unwatched episodes in shuffle queue", "shuffleOrderNavigation": "Shuffle Order Navigation", diff --git a/lib/i18n/strings_it.i18n.json b/lib/i18n/strings_it.i18n.json index efe57079..b206b80d 100644 --- a/lib/i18n/strings_it.i18n.json +++ b/lib/i18n/strings_it.i18n.json @@ -100,6 +100,8 @@ "secondsUnit": "${seconds} secondi", "defaultSleepTimer": "Timer spegnimento predefinito", "minutesUnit": "${minutes} minuti", + "rememberTrackSelections": "Ricorda selezioni tracce per serie/film", + "rememberTrackSelectionsDescription": "Salva automaticamente le preferenze delle lingue audio e sottotitoli quando cambi tracce durante la riproduzione", "unwatchedOnly": "Solo non guardati", "unwatchedOnlyDescription": "Includi solo gli episodi non guardati nella coda di riproduzione casuale", "shuffleOrderNavigation": "Navigazione in ordine casuale", diff --git a/lib/i18n/strings_nl.i18n.json b/lib/i18n/strings_nl.i18n.json index 68f2084a..ec1e906f 100644 --- a/lib/i18n/strings_nl.i18n.json +++ b/lib/i18n/strings_nl.i18n.json @@ -100,6 +100,8 @@ "secondsUnit": "${seconds} seconden", "defaultSleepTimer": "Standaard slaap timer", "minutesUnit": "bij ${minutes} minuten", + "rememberTrackSelections": "Onthoud track selecties per serie/film", + "rememberTrackSelectionsDescription": "Bewaar automatisch audio- en ondertiteltaalvoorkeuren wanneer je tracks wijzigt tijdens afspelen", "unwatchedOnly": "Alleen ongekeken", "unwatchedOnlyDescription": "Alleen ongekeken afleveringen opnemen in willekeurige wachtrij", "shuffleOrderNavigation": "Willekeurige volgorde navigatie", diff --git a/lib/i18n/strings_sv.i18n.json b/lib/i18n/strings_sv.i18n.json index c81bc3fc..e118824d 100644 --- a/lib/i18n/strings_sv.i18n.json +++ b/lib/i18n/strings_sv.i18n.json @@ -100,6 +100,8 @@ "secondsUnit": "${seconds} sekunder", "defaultSleepTimer": "Standard sovtimer", "minutesUnit": "${minutes} minuter", + "rememberTrackSelections": "Kom ihåg spårval per serie/film", + "rememberTrackSelectionsDescription": "Spara automatiskt ljud- och undertextspråkpreferenser när du ändrar spår under uppspelning", "unwatchedOnly": "Endast osedda", "unwatchedOnlyDescription": "Inkludera endast osedda avsnitt i blandningskön", "shuffleOrderNavigation": "Blandningsordning-navigation", diff --git a/lib/models/plex_metadata.dart b/lib/models/plex_metadata.dart index 17f360f0..4a667cf8 100644 --- a/lib/models/plex_metadata.dart +++ b/lib/models/plex_metadata.dart @@ -38,6 +38,8 @@ class PlexMetadata { final int? viewedLeafCount; // Number of watched episodes in a series/season @JsonKey(name: 'Role') final List? role; // Cast members + final String? audioLanguage; // Per-media preferred audio language + final String? subtitleLanguage; // Per-media preferred subtitle language // Transient field for clear logo (extracted from Image array) String? _clearLogo; @@ -75,6 +77,8 @@ class PlexMetadata { this.leafCount, this.viewedLeafCount, this.role, + this.audioLanguage, + this.subtitleLanguage, }); /// Create a copy of this metadata with optional field overrides @@ -110,6 +114,8 @@ class PlexMetadata { int? leafCount, int? viewedLeafCount, List? role, + String? audioLanguage, + String? subtitleLanguage, }) { final copy = PlexMetadata( ratingKey: ratingKey ?? this.ratingKey, @@ -143,6 +149,8 @@ class PlexMetadata { leafCount: leafCount ?? this.leafCount, viewedLeafCount: viewedLeafCount ?? this.viewedLeafCount, role: role ?? this.role, + audioLanguage: audioLanguage ?? this.audioLanguage, + subtitleLanguage: subtitleLanguage ?? this.subtitleLanguage, ); // Preserve clearLogo copy._clearLogo = _clearLogo; diff --git a/lib/models/plex_metadata.g.dart b/lib/models/plex_metadata.g.dart index 5cf19850..9d6de3a1 100644 --- a/lib/models/plex_metadata.g.dart +++ b/lib/models/plex_metadata.g.dart @@ -40,6 +40,8 @@ PlexMetadata _$PlexMetadataFromJson(Map json) => PlexMetadata( role: (json['Role'] as List?) ?.map((e) => PlexRole.fromJson(e as Map)) .toList(), + audioLanguage: json['audioLanguage'] as String?, + subtitleLanguage: json['subtitleLanguage'] as String?, ); Map _$PlexMetadataToJson(PlexMetadata instance) => @@ -75,4 +77,6 @@ Map _$PlexMetadataToJson(PlexMetadata instance) => 'leafCount': instance.leafCount, 'viewedLeafCount': instance.viewedLeafCount, 'Role': instance.role, + 'audioLanguage': instance.audioLanguage, + 'subtitleLanguage': instance.subtitleLanguage, }; diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 20b08546..9a909197 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -33,6 +33,7 @@ class _SettingsScreenState extends State { int _seekTimeSmall = 10; int _seekTimeLarge = 30; int _sleepTimerDuration = 30; + bool _rememberTrackSelections = true; // Update checking state bool _isCheckingForUpdate = false; @@ -55,6 +56,7 @@ class _SettingsScreenState extends State { _seekTimeSmall = _settingsService.getSeekTimeSmall(); _seekTimeLarge = _settingsService.getSeekTimeLarge(); _sleepTimerDuration = _settingsService.getSleepTimerDuration(); + _rememberTrackSelections = _settingsService.getRememberTrackSelections(); _isLoading = false; }); } @@ -264,6 +266,18 @@ class _SettingsScreenState extends State { trailing: const Icon(Icons.chevron_right), onTap: () => _showSleepTimerDurationDialog(), ), + SwitchListTile( + secondary: const Icon(Icons.bookmark), + title: Text(t.settings.rememberTrackSelections), + subtitle: Text(t.settings.rememberTrackSelectionsDescription), + value: _rememberTrackSelections, + onChanged: (value) async { + setState(() { + _rememberTrackSelections = value; + }); + await _settingsService.setRememberTrackSelections(value); + }, + ), ], ), ); diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 2dd6e7ff..37c766b0 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -7,6 +7,7 @@ import 'package:media_kit_video/media_kit_video.dart'; import 'package:os_media_controls/os_media_controls.dart'; import 'package:provider/provider.dart'; +import '../client/plex_client.dart'; import '../models/plex_media_version.dart'; import '../models/plex_metadata.dart'; import '../models/plex_user_profile.dart'; @@ -1073,6 +1074,34 @@ class VideoPlayerScreenState extends State { return title.contains('forced'); } + /// Checks if a track language matches a preferred language + /// + /// Handles both 2-letter (ISO 639-1) and 3-letter (ISO 639-2) codes + /// Also handles bibliographic variants and region codes (e.g., "en-US") + bool _languageMatches(String? trackLanguage, String? preferredLanguage) { + if (trackLanguage == null || preferredLanguage == null) { + return false; + } + + final track = trackLanguage.toLowerCase(); + final preferred = preferredLanguage.toLowerCase(); + + // Direct match + if (track == preferred) return true; + + // Extract base language codes (handle region codes like "en-US") + final trackBase = track.split('-').first; + final preferredBase = preferred.split('-').first; + + if (trackBase == preferredBase) return true; + + // Get all variations of the preferred language (e.g., "en" → ["en", "eng"]) + final variations = LanguageCodes.getVariations(preferredBase); + + // Check if track's base code matches any variation + return variations.contains(trackBase); + } + void _waitForTracksAndApply() async { // Helper function to process tracks Future processTracks(Tracks tracks) async { @@ -1102,7 +1131,7 @@ class VideoPlayerScreenState extends State { ); } - // Select audio track with priority: preferred > user profile > default > first + // Select audio track with priority: preferred > per-media > user profile > default > first appLogger.d('Audio track selection'); if (realAudioTracks.isNotEmpty) { AudioTrack? trackToSelect; @@ -1126,20 +1155,46 @@ class VideoPlayerScreenState extends State { appLogger.d('Priority 1: No preferred track from navigation'); } - // Priority 2: If no preferred track matched, try user profile preferences + // Priority 2: If no preferred track matched, try per-media language preference + if (trackToSelect == null && widget.metadata.audioLanguage != null) { + appLogger.d( + 'Priority 2: Checking per-media audio language preference', + ); + appLogger.d( + ' Per-media audio language: ${widget.metadata.audioLanguage}', + ); + trackToSelect = realAudioTracks.firstWhere( + (track) => + _languageMatches(track.language, widget.metadata.audioLanguage), + orElse: () => realAudioTracks.first, + ); + if (_languageMatches( + trackToSelect.language, + widget.metadata.audioLanguage, + )) { + appLogger.d(' Matched per-media audio language preference'); + } else { + appLogger.d(' No match found for per-media audio language'); + trackToSelect = null; + } + } else if (trackToSelect == null) { + appLogger.d('Priority 2: No per-media audio language preference'); + } + + // Priority 3: If no preferred track matched, try user profile preferences if (trackToSelect == null && profileSettings != null) { - appLogger.d('Priority 2: Checking user profile preferences'); + appLogger.d('Priority 3: Checking user profile preferences'); trackToSelect = _findAudioTrackByProfile( realAudioTracks, profileSettings, ); } else if (trackToSelect == null) { - appLogger.d('Priority 2: No user profile available'); + appLogger.d('Priority 3: No user profile available'); } - // Priority 3: If no match, use default or first track + // Priority 4: If no match, use default or first track if (trackToSelect == null) { - appLogger.d('Priority 3: Using default or first available track'); + appLogger.d('Priority 4: Using default or first available track'); trackToSelect = realAudioTracks.firstWhere( (t) => t.isDefault == true, orElse: () => realAudioTracks.first, @@ -1158,7 +1213,7 @@ class VideoPlayerScreenState extends State { appLogger.d('No audio tracks available'); } - // Select subtitle track with priority: preferred > user profile > default > off + // Select subtitle track with priority: preferred > per-media > user profile > default > off appLogger.d('Subtitle track selection'); SubtitleTrack? subtitleToSelect; @@ -1187,11 +1242,47 @@ class VideoPlayerScreenState extends State { appLogger.d('Priority 1: No preferred track from navigation'); } - // Priority 2: If no preferred match, apply user profile preferences + // Priority 2: If no preferred match, try per-media language preference + if (subtitleToSelect == null && + widget.metadata.subtitleLanguage != null) { + appLogger.d( + 'Priority 2: Checking per-media subtitle language preference', + ); + appLogger.d( + ' Per-media subtitle language: ${widget.metadata.subtitleLanguage}', + ); + // Check if subtitle should be disabled + if (widget.metadata.subtitleLanguage == 'none' || + widget.metadata.subtitleLanguage!.isEmpty) { + appLogger.d(' Per-media preference: Subtitles OFF'); + subtitleToSelect = SubtitleTrack.no(); + } else if (realSubtitleTracks.isNotEmpty) { + final matchedTrack = realSubtitleTracks.firstWhere( + (track) => _languageMatches( + track.language, + widget.metadata.subtitleLanguage, + ), + orElse: () => realSubtitleTracks.first, + ); + if (_languageMatches( + matchedTrack.language, + widget.metadata.subtitleLanguage, + )) { + subtitleToSelect = matchedTrack; + appLogger.d(' Matched per-media subtitle language preference'); + } else { + appLogger.d(' No match found for per-media subtitle language'); + } + } + } else if (subtitleToSelect == null) { + appLogger.d('Priority 2: No per-media subtitle language preference'); + } + + // Priority 3: If no preferred match, apply user profile preferences if (subtitleToSelect == null && profileSettings != null && realSubtitleTracks.isNotEmpty) { - appLogger.d('Priority 2: Checking user profile preferences'); + appLogger.d('Priority 3: Checking user profile preferences'); // Get the currently selected audio track final currentAudioTrack = realAudioTracks.firstWhere( (t) => t.id == player!.state.track.audio.id, @@ -1203,12 +1294,12 @@ class VideoPlayerScreenState extends State { selectedAudioTrack: currentAudioTrack, ); } else if (subtitleToSelect == null && realSubtitleTracks.isNotEmpty) { - appLogger.d('Priority 2: No user profile available'); + appLogger.d('Priority 3: No user profile available'); } - // Priority 3: If no profile match, check for default subtitle + // Priority 4: If no profile match, check for default subtitle if (subtitleToSelect == null && realSubtitleTracks.isNotEmpty) { - appLogger.d('Priority 3: Checking for default subtitle track'); + appLogger.d('Priority 4: Checking for default subtitle track'); final defaultTrackIndex = realSubtitleTracks.indexWhere( (t) => t.isDefault == true, ); @@ -1224,7 +1315,7 @@ class VideoPlayerScreenState extends State { // If still no subtitle selected, turn off if (subtitleToSelect == null) { - appLogger.d('Priority 4: No subtitle selected - Subtitles OFF'); + appLogger.d('Priority 5: No subtitle selected - Subtitles OFF'); subtitleToSelect = SubtitleTrack.no(); } @@ -1485,6 +1576,94 @@ class VideoPlayerScreenState extends State { await _navigateToEpisode(_previousEpisode!); } + /// Handle audio track changes from the user - save as per-media preference if enabled + Future _onAudioTrackChanged(AudioTrack track) async { + final settings = await SettingsService.getInstance(); + + // Only save if remember track selections is enabled + if (!settings.getRememberTrackSelections()) { + return; + } + + // Extract language code from the track + final languageCode = track.language; + if (languageCode == null || languageCode.isEmpty) { + appLogger.d('Audio track has no language code, not saving preference'); + return; + } + + // Determine which ratingKey to use + // For TV shows: use grandparentRatingKey (series level) + // For movies: use ratingKey (movie level) + final isEpisode = widget.metadata.type.toLowerCase() == 'episode'; + final targetRatingKey = isEpisode + ? (widget.metadata.grandparentRatingKey ?? widget.metadata.ratingKey) + : widget.metadata.ratingKey; + + appLogger.i( + 'Saving audio language preference: $languageCode for ${isEpisode ? "series" : "movie"} (ratingKey: $targetRatingKey)', + ); + + try { + if (!mounted) return; + final client = context.read(); + await client.setMetadataPreferences( + targetRatingKey, + audioLanguage: languageCode, + ); + appLogger.d('Successfully saved audio language preference'); + } catch (e) { + appLogger.e('Failed to save audio language preference', error: e); + } + } + + /// Handle subtitle track changes from the user - save as per-media preference if enabled + Future _onSubtitleTrackChanged(SubtitleTrack track) async { + final settings = await SettingsService.getInstance(); + + // Only save if remember track selections is enabled + if (!settings.getRememberTrackSelections()) { + return; + } + + // Handle "Off" selection + String? languageCode; + if (track.id == 'no') { + languageCode = 'none'; + appLogger.i('User turned subtitles off, saving preference'); + } else { + languageCode = track.language; + if (languageCode == null || languageCode.isEmpty) { + appLogger.d( + 'Subtitle track has no language code, not saving preference', + ); + return; + } + } + + // Determine which ratingKey to use + final isEpisode = widget.metadata.type.toLowerCase() == 'episode'; + final targetRatingKey = isEpisode + ? (widget.metadata.grandparentRatingKey ?? widget.metadata.ratingKey) + : widget.metadata.ratingKey; + + appLogger.i( + 'Saving subtitle language preference: $languageCode for ${isEpisode ? "series" : "movie"} (ratingKey: $targetRatingKey)', + ); + + try { + if (!mounted) return; + final client = context.read(); + await client.setMetadataPreferences( + targetRatingKey, + subtitleLanguage: languageCode, + ); + appLogger.d('Successfully saved subtitle language preference'); + } catch (e) { + appLogger.e('Failed to save subtitle language preference', error: e); + } + } + /// Set flag to skip orientation restoration when replacing with another video void setReplacingWithVideo() { _isReplacingWithVideo = true; @@ -1615,6 +1794,8 @@ class VideoPlayerScreenState extends State { selectedMediaIndex: widget.selectedMediaIndex, boxFitMode: _boxFitMode, onCycleBoxFitMode: _cycleBoxFitMode, + onAudioTrackChanged: _onAudioTrackChanged, + onSubtitleTrackChanged: _onSubtitleTrackChanged, ), ); }, diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index 8c745318..8dc02100 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -43,6 +43,7 @@ class SettingsService { static const String _keyShuffleOrderNavigation = 'shuffle_order_navigation'; static const String _keyShuffleLoopQueue = 'shuffle_loop_queue'; static const String _keyAppLocale = 'app_locale'; + static const String _keyRememberTrackSelections = 'remember_track_selections'; static SettingsService? _instance; late SharedPreferences _prefs; @@ -828,6 +829,17 @@ class SettingsService { return _prefs.getBool(_keyShuffleLoopQueue) ?? false; // Default: false } + // Track Selection Settings + + /// Remember Track Selections - Save per-media audio/subtitle language preferences + Future setRememberTrackSelections(bool enabled) async { + await _prefs.setBool(_keyRememberTrackSelections, enabled); + } + + bool getRememberTrackSelections() { + return _prefs.getBool(_keyRememberTrackSelections) ?? true; // Default: true + } + // Reset all settings to defaults Future resetAllSettings() async { await Future.wait([ @@ -860,6 +872,7 @@ class SettingsService { _prefs.remove(_keyShuffleOrderNavigation), _prefs.remove(_keyShuffleLoopQueue), _prefs.remove(_keyAppLocale), + _prefs.remove(_keyRememberTrackSelections), ]); } @@ -891,6 +904,7 @@ class SettingsService { 'keyboardHotkeys': hotkeys.map( (key, value) => MapEntry(key, _serializeHotKey(value)), ), + 'rememberTrackSelections': getRememberTrackSelections(), }; } } diff --git a/lib/services/track_selection_service.dart b/lib/services/track_selection_service.dart index d852abef..11c7da2b 100644 --- a/lib/services/track_selection_service.dart +++ b/lib/services/track_selection_service.dart @@ -1,18 +1,43 @@ import '../models/plex_media_info.dart'; +import '../models/plex_metadata.dart'; import '../models/plex_user_profile.dart'; +import '../utils/language_codes.dart'; /// Service for selecting audio and subtitle tracks based on user preferences class TrackSelectionService { /// Selects the best audio track based on user preferences /// + /// Priority order: + /// 1. Per-media preferred audio language (from metadata.audioLanguage) + /// 2. Profile-wide language preferences (if auto-select is enabled) + /// 3. Plex's selected track (if auto-select is disabled) + /// 4. First track + /// /// Returns the selected audio track, or null if no suitable track is found static PlexAudioTrack? selectAudioTrack( List tracks, - PlexUserProfile profile, - ) { + PlexUserProfile profile, { + PlexMetadata? metadata, + }) { if (tracks.isEmpty) return null; - // If auto-select is disabled, use Plex's selected track + // Priority 1: Check for per-media audio language preference + if (metadata?.audioLanguage != null) { + final perMediaTrack = tracks.firstWhere( + (track) => + _matchesLanguage(track.languageCode, metadata!.audioLanguage), + orElse: () => tracks.first, + ); + // Only use it if we actually found a matching track + if (_matchesLanguage( + perMediaTrack.languageCode, + metadata!.audioLanguage, + )) { + return perMediaTrack; + } + } + + // Priority 2: If auto-select is disabled, use Plex's selected track if (!profile.autoSelectAudio) { return tracks.firstWhere( (track) => track.selected, @@ -20,7 +45,7 @@ class TrackSelectionService { ); } - // Build list of preferred language codes + // Priority 3: Use profile-wide language preferences final preferredLanguages = []; if (profile.defaultAudioLanguage != null) { preferredLanguages.add(profile.defaultAudioLanguage!); @@ -52,14 +77,44 @@ class TrackSelectionService { /// Selects the best subtitle track based on user preferences /// + /// Priority order: + /// 1. Per-media preferred subtitle language (from metadata.subtitleLanguage) + /// 2. Profile-wide subtitle preferences (based on auto-select mode) + /// 3. Disabled (null) + /// /// Returns the selected subtitle track, or null if subtitles should be disabled static PlexSubtitleTrack? selectSubtitleTrack( List tracks, PlexUserProfile profile, - PlexAudioTrack? selectedAudioTrack, - ) { + PlexAudioTrack? selectedAudioTrack, { + PlexMetadata? metadata, + }) { if (tracks.isEmpty) return null; + // Priority 1: Check for per-media subtitle language preference + if (metadata?.subtitleLanguage != null && + metadata!.subtitleLanguage!.isNotEmpty) { + // Check if subtitle should be disabled (empty string or "none") + if (metadata.subtitleLanguage == 'none' || + metadata.subtitleLanguage == '') { + return null; + } + + final perMediaTrack = tracks.firstWhere( + (track) => + _matchesLanguage(track.languageCode, metadata.subtitleLanguage), + orElse: () => tracks.first, + ); + // Only use it if we actually found a matching track + if (_matchesLanguage( + perMediaTrack.languageCode, + metadata.subtitleLanguage, + )) { + return perMediaTrack; + } + } + + // Priority 2: Use profile-wide subtitle preferences // Mode 0: Manually selected - return null to disable subtitles if (profile.autoSelectSubtitle == 0) { return null; @@ -217,6 +272,7 @@ class TrackSelectionService { /// Checks if a language code matches a preferred language /// /// Handles both 2-letter (ISO 639-1) and 3-letter (ISO 639-2) codes + /// Also handles bibliographic variants and region codes (e.g., "en-US") static bool _matchesLanguage( String? trackLanguage, String? preferredLanguage, @@ -231,49 +287,16 @@ class TrackSelectionService { // Direct match if (track == preferred) return true; - // Handle common 2-letter to 3-letter mappings - final languageMap = { - 'en': 'eng', - 'es': 'spa', - 'fr': 'fra', - 'de': 'deu', - 'it': 'ita', - 'pt': 'por', - 'ja': 'jpn', - 'ko': 'kor', - 'zh': 'zho', - 'ru': 'rus', - 'ar': 'ara', - 'hi': 'hin', - 'nl': 'nld', - 'pl': 'pol', - 'tr': 'tur', - 'sv': 'swe', - 'no': 'nor', - 'da': 'dan', - 'fi': 'fin', - 'cs': 'ces', - 'hu': 'hun', - 'ro': 'ron', - 'th': 'tha', - 'vi': 'vie', - 'id': 'ind', - 'uk': 'ukr', - 'el': 'ell', - 'he': 'heb', - }; + // Extract base language codes (handle region codes like "en-US") + final trackBase = track.split('-').first; + final preferredBase = preferred.split('-').first; - // Try mapping preferred to 3-letter and compare - if (languageMap[preferred] == track) return true; + if (trackBase == preferredBase) return true; - // Try mapping track to 3-letter and compare with preferred 3-letter - if (languageMap[track] == preferred) return true; + // Get all variations of the preferred language (e.g., "en" → ["en", "eng"]) + final variations = LanguageCodes.getVariations(preferredBase); - // Try reverse mapping (3-letter to 2-letter) - final reverseMap = languageMap.map((k, v) => MapEntry(v, k)); - if (reverseMap[preferred] == track) return true; - if (reverseMap[track] == preferred) return true; - - return false; + // Check if track's base code matches any variation + return variations.contains(trackBase); } } diff --git a/lib/widgets/video_controls/sheets/audio_track_sheet.dart b/lib/widgets/video_controls/sheets/audio_track_sheet.dart index ba7cd991..40354f17 100644 --- a/lib/widgets/video_controls/sheets/audio_track_sheet.dart +++ b/lib/widgets/video_controls/sheets/audio_track_sheet.dart @@ -5,8 +5,9 @@ import '../../../i18n/strings.g.dart'; /// Bottom sheet for selecting audio tracks class AudioTrackSheet extends StatelessWidget { final Player player; + final Function(AudioTrack)? onTrackChanged; - const AudioTrackSheet({super.key, required this.player}); + const AudioTrackSheet({super.key, required this.player, this.onTrackChanged}); static BoxConstraints getBottomSheetConstraints(BuildContext context) { final size = MediaQuery.of(context).size; @@ -19,13 +20,18 @@ class AudioTrackSheet extends StatelessWidget { ); } - static void show(BuildContext context, Player player) { + static void show( + BuildContext context, + Player player, { + Function(AudioTrack)? onTrackChanged, + }) { showModalBottomSheet( context: context, backgroundColor: Colors.grey[900], isScrollControlled: true, constraints: getBottomSheetConstraints(context), - builder: (context) => AudioTrackSheet(player: player), + builder: (context) => + AudioTrackSheet(player: player, onTrackChanged: onTrackChanged), ); } @@ -130,6 +136,7 @@ class AudioTrackSheet extends StatelessWidget { : null, onTap: () { player.setAudioTrack(audioTrack); + onTrackChanged?.call(audioTrack); Navigator.pop(context); }, ); diff --git a/lib/widgets/video_controls/sheets/subtitle_track_sheet.dart b/lib/widgets/video_controls/sheets/subtitle_track_sheet.dart index ab3858b7..4f375627 100644 --- a/lib/widgets/video_controls/sheets/subtitle_track_sheet.dart +++ b/lib/widgets/video_controls/sheets/subtitle_track_sheet.dart @@ -5,8 +5,13 @@ import '../../../i18n/strings.g.dart'; /// Bottom sheet for selecting subtitle tracks class SubtitleTrackSheet extends StatelessWidget { final Player player; + final Function(SubtitleTrack)? onTrackChanged; - const SubtitleTrackSheet({super.key, required this.player}); + const SubtitleTrackSheet({ + super.key, + required this.player, + this.onTrackChanged, + }); static BoxConstraints getBottomSheetConstraints(BuildContext context) { final size = MediaQuery.of(context).size; @@ -19,13 +24,18 @@ class SubtitleTrackSheet extends StatelessWidget { ); } - static void show(BuildContext context, Player player) { + static void show( + BuildContext context, + Player player, { + Function(SubtitleTrack)? onTrackChanged, + }) { showModalBottomSheet( context: context, backgroundColor: Colors.grey[900], isScrollControlled: true, constraints: getBottomSheetConstraints(context), - builder: (context) => SubtitleTrackSheet(player: player), + builder: (context) => + SubtitleTrackSheet(player: player, onTrackChanged: onTrackChanged), ); } @@ -113,6 +123,7 @@ class SubtitleTrackSheet extends StatelessWidget { : null, onTap: () { player.setSubtitleTrack(SubtitleTrack.no()); + onTrackChanged?.call(SubtitleTrack.no()); Navigator.pop(context); }, ); @@ -167,6 +178,7 @@ class SubtitleTrackSheet extends StatelessWidget { : null, onTap: () { player.setSubtitleTrack(subtitle); + onTrackChanged?.call(subtitle); Navigator.pop(context); }, ); diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index c3bc93b4..fde60db0 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -38,6 +38,8 @@ Widget plexVideoControlsBuilder( int? selectedMediaIndex, int boxFitMode = 0, VoidCallback? onCycleBoxFitMode, + Function(AudioTrack)? onAudioTrackChanged, + Function(SubtitleTrack)? onSubtitleTrackChanged, }) { return PlexVideoControls( player: player, @@ -48,6 +50,8 @@ Widget plexVideoControlsBuilder( selectedMediaIndex: selectedMediaIndex ?? 0, boxFitMode: boxFitMode, onCycleBoxFitMode: onCycleBoxFitMode, + onAudioTrackChanged: onAudioTrackChanged, + onSubtitleTrackChanged: onSubtitleTrackChanged, ); } @@ -60,6 +64,8 @@ class PlexVideoControls extends StatefulWidget { final int selectedMediaIndex; final int boxFitMode; final VoidCallback? onCycleBoxFitMode; + final Function(AudioTrack)? onAudioTrackChanged; + final Function(SubtitleTrack)? onSubtitleTrackChanged; const PlexVideoControls({ super.key, @@ -71,6 +77,8 @@ class PlexVideoControls extends StatefulWidget { this.selectedMediaIndex = 0, this.boxFitMode = 0, this.onCycleBoxFitMode, + this.onAudioTrackChanged, + this.onSubtitleTrackChanged, }); @override @@ -464,13 +472,20 @@ class _PlexVideoControlsState extends State if (_hasMultipleAudioTracks(tracks)) VideoControlButton( icon: Icons.audiotrack, - onPressed: () => AudioTrackSheet.show(context, widget.player), + onPressed: () => AudioTrackSheet.show( + context, + widget.player, + onTrackChanged: widget.onAudioTrackChanged, + ), ), if (_hasSubtitles(tracks)) VideoControlButton( icon: Icons.subtitles, - onPressed: () => - SubtitleTrackSheet.show(context, widget.player), + onPressed: () => SubtitleTrackSheet.show( + context, + widget.player, + onTrackChanged: widget.onSubtitleTrackChanged, + ), ), if (_chapters.isNotEmpty) VideoControlButton( From 0101dbde713a8a6f146f9ee4b4d381d279363888 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 14 Nov 2025 23:10:05 +0100 Subject: [PATCH 08/29] fix: make media controls follow app lifecycle --- lib/screens/video_player_screen.dart | 36 +++++++++++++++++++++++++++- pubspec.lock | 4 ++-- pubspec.yaml | 2 +- 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 37c766b0..6f40aed7 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -44,7 +44,8 @@ class VideoPlayerScreen extends StatefulWidget { State createState() => VideoPlayerScreenState(); } -class VideoPlayerScreenState extends State { +class VideoPlayerScreenState extends State + with WidgetsBindingObserver { Player? player; VideoController? controller; bool _isPlayerInitialized = false; @@ -91,6 +92,9 @@ class VideoPlayerScreenState extends State { appLogger.d('Preferred subtitle track: $subtitleDesc'); } + // Register app lifecycle observer + WidgetsBinding.instance.addObserver(this); + // Initialize player asynchronously with buffer size from settings _initializePlayer(); } @@ -121,6 +125,33 @@ class VideoPlayerScreenState extends State { }); } + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + super.didChangeAppLifecycleState(state); + + switch (state) { + case AppLifecycleState.inactive: + case AppLifecycleState.paused: + // Clear media controls when app goes to background or screen locks + // (we don't support background playback) + OsMediaControls.clear(); + appLogger.d('Media controls cleared due to app lifecycle state: $state'); + break; + case AppLifecycleState.resumed: + // Restore media controls when app is resumed + if (_isPlayerInitialized && mounted) { + _updateMediaMetadata(); + _updateMediaControlsPlaybackState(); + appLogger.d('Media controls restored on app resume'); + } + break; + case AppLifecycleState.detached: + case AppLifecycleState.hidden: + // No action needed for these states + break; + } + } + Future _initializePlayer() async { try { // Load buffer size from settings @@ -650,6 +681,9 @@ class VideoPlayerScreenState extends State { @override void dispose() { + // Unregister app lifecycle observer + WidgetsBinding.instance.removeObserver(this); + // Stop progress tracking _progressTimer?.cancel(); diff --git a/pubspec.lock b/pubspec.lock index 328c7b93..106c8339 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -644,8 +644,8 @@ packages: dependency: "direct main" description: path: "." - ref: "53d803e1c228eb7eab9ca642119d404c21f4a52f" - resolved-ref: "53d803e1c228eb7eab9ca642119d404c21f4a52f" + ref: a196b022dcbcc3433bae9233be179f02062327e7 + resolved-ref: a196b022dcbcc3433bae9233be179f02062327e7 url: "https://github.com/edde746/os-media-controls" source: git version: "0.0.2" diff --git a/pubspec.yaml b/pubspec.yaml index 74002982..5e3b7f22 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -31,7 +31,7 @@ dependencies: os_media_controls: git: url: https://github.com/edde746/os-media-controls - ref: 53d803e1c228eb7eab9ca642119d404c21f4a52f + ref: a196b022dcbcc3433bae9233be179f02062327e7 dependency_overrides: media_kit: From 9bb9879dc45f73520743e67fc872320cba3a2139 Mon Sep 17 00:00:00 2001 From: Jamal <15986930+jamal2362@users.noreply.github.com> Date: Fri, 14 Nov 2025 23:46:34 +0100 Subject: [PATCH 09/29] Add german translation --- lib/i18n/strings_de.i18n.json | 372 ++++++++++++++++++++++++++++++++++ 1 file changed, 372 insertions(+) create mode 100644 lib/i18n/strings_de.i18n.json diff --git a/lib/i18n/strings_de.i18n.json b/lib/i18n/strings_de.i18n.json new file mode 100644 index 00000000..176effea --- /dev/null +++ b/lib/i18n/strings_de.i18n.json @@ -0,0 +1,372 @@ +{ + "app": { + "title": "Plezy", + "loading": "Lädt..." + }, + "auth": { + "signInWithPlex": "Mit Plex anmelden", + "showQRCode": "QR-Code anzeigen", + "cancel": "Abbrechen", + "authenticate": "Authentifizieren", + "retry": "Erneut versuchen", + "debugEnterToken": "Debug: Plex-Token eingeben", + "plexTokenLabel": "Plex-Auth-Token", + "plexTokenHint": "Plex.tv-Token eingeben", + "authenticationTimeout": "Authentifizierung abgelaufen. Bitte erneut versuchen.", + "scanQRCodeInstruction": "Diesen QR-Code mit einem bei Plex angemeldeten Gerät scannen, um zu authentifizieren.", + "waitingForAuth": "Warte auf Authentifizierung...\nBitte Anmeldung im Browser abschließen." + }, + "common": { + "cancel": "Abbrechen", + "save": "Speichern", + "close": "Schließen", + "clear": "Leeren", + "reset": "Zurücksetzen", + "later": "Später", + "submit": "Senden", + "confirm": "Bestätigen", + "retry": "Erneut versuchen", + "playNow": "Jetzt abspielen", + "logout": "Abmelden", + "online": "Online", + "offline": "Offline", + "owned": "Eigentum", + "shared": "Geteilt", + "current": "AKTUELL", + "unknown": "Unbekannt", + "refresh": "Aktualisieren", + "yes": "Ja", + "no": "Nein", + "server": "Server" + }, + "screens": { + "licenses": "Lizenzen", + "selectServer": "Server auswählen", + "switchProfile": "Profil wechseln", + "subtitleStyling": "Untertitel-Stil", + "search": "Suche", + "logs": "Protokolle" + }, + "update": { + "available": "Update verfügbar", + "versionAvailable": "Version ${version} ist verfügbar", + "currentVersion": "Aktuell: ${version}", + "skipVersion": "Diese Version überspringen", + "viewRelease": "Release anzeigen", + "latestVersion": "Aktuellste Version installiert", + "checkFailed": "Fehler bei der Updateprüfung" + }, + "settings": { + "title": "Einstellungen", + "language": "Sprache", + "theme": "Theme", + "appearance": "Darstellung", + "videoPlayback": "Videowiedergabe", + "shufflePlay": "Zufallswiedergabe", + "advanced": "Erweitert", + "useSeasonPostersDescription": "Staffelposter statt Serienposter für Episoden anzeigen", + "showHeroSectionDescription": "Bereich mit empfohlenen Inhalten auf der Startseite anzeigen", + "secondsLabel": "Sekunden", + "minutesLabel": "Minuten", + "secondsShort": "s", + "minutesShort": "m", + "durationHint": "Dauer eingeben (${min}-${max})", + "systemTheme": "System", + "systemThemeDescription": "Systemeinstellungen folgen", + "lightTheme": "Hell", + "darkTheme": "Dunkel", + "libraryDensity": "Bibliotheksdichte", + "compact": "Kompakt", + "compactDescription": "Kleinere Karten, mehr Elemente sichtbar", + "normal": "Normal", + "normalDescription": "Standardgröße", + "comfortable": "Großzügig", + "comfortableDescription": "Größere Karten, weniger Elemente sichtbar", + "viewMode": "Ansichtsmodus", + "gridView": "Raster", + "gridViewDescription": "Elemente im Raster anzeigen", + "listView": "Liste", + "listViewDescription": "Elemente in Listenansicht anzeigen", + "useSeasonPosters": "Staffelposter verwenden", + "showHeroSection": "Hero-Bereich anzeigen", + "hardwareDecoding": "Hardware-Decodierung", + "hardwareDecodingDescription": "Hardwarebeschleunigung verwenden, wenn verfügbar", + "bufferSize": "Puffergröße", + "bufferSizeMB": "${size}MB", + "subtitleStyling": "Untertitel-Stil", + "subtitleStylingDescription": "Aussehen von Untertiteln anpassen", + "smallSkipDuration": "Kleine Sprungdauer", + "largeSkipDuration": "Große Sprungdauer", + "secondsUnit": "${seconds} Sekunden", + "defaultSleepTimer": "Standard-Sleep-Timer", + "minutesUnit": "${minutes} Minuten", + "rememberTrackSelections": "Spurauswahl pro Serie/Film merken", + "rememberTrackSelectionsDescription": "Audio- und Untertitelsprache automatisch speichern, wenn während der Wiedergabe geändert", + "unwatchedOnly": "Nur ungesehene", + "unwatchedOnlyDescription": "Nur ungesehene Episoden in die Shuffle-Warteschlange aufnehmen", + "shuffleOrderNavigation": "Navigation der Shuffle-Reihenfolge", + "shuffleOrderNavigationDescription": "Weiter/Zurück folgt der zufälligen Reihenfolge", + "loopShuffleQueue": "Shuffle-Warteschlange wiederholen", + "loopShuffleQueueDescription": "Warteschlange neu starten, wenn das Ende erreicht ist", + "videoPlayerControls": "Videoplayer-Steuerung", + "keyboardShortcuts": "Tastenkürzel", + "keyboardShortcutsDescription": "Tastenkürzel anpassen", + "debugLogging": "Debug-Protokollierung", + "debugLoggingDescription": "Detaillierte Protokolle zur Fehleranalyse aktivieren", + "viewLogs": "Protokolle anzeigen", + "viewLogsDescription": "App-Protokolle anzeigen", + "clearCache": "Cache löschen", + "clearCacheDescription": "Löscht alle zwischengespeicherten Bilder und Daten. Die App kann danach langsamer laden.", + "clearCacheSuccess": "Cache erfolgreich gelöscht", + "resetSettings": "Einstellungen zurücksetzen", + "resetSettingsDescription": "Alle Einstellungen auf Standard zurücksetzen. Dies kann nicht rückgängig gemacht werden.", + "resetSettingsSuccess": "Einstellungen erfolgreich zurückgesetzt", + "shortcutsReset": "Tastenkürzel auf Standard zurückgesetzt", + "about": "Über", + "aboutDescription": "App-Informationen und Lizenzen", + "updates": "Updates", + "updateAvailable": "Update verfügbar", + "checkForUpdates": "Nach Updates suchen", + "validationErrorEnterNumber": "Bitte eine gültige Zahl eingeben", + "validationErrorDuration": "Dauer muss zwischen ${min} und ${max} ${unit} liegen", + "shortcutAlreadyAssigned": "Tastenkürzel bereits zugewiesen an ${action}", + "shortcutUpdated": "Tastenkürzel aktualisiert für ${action}" + }, + "search": { + "hint": "Filme, Serien, Musik suchen...", + "tryDifferentTerm": "Anderen Suchbegriff versuchen", + "searchYourMedia": "In den eigenen Medien suchen", + "enterTitleActorOrKeyword": "Titel, Schauspieler oder Stichwort eingeben" + }, + "hotkeys": { + "setShortcutFor": "Tastenkürzel festlegen für ${actionName}", + "clearShortcut": "Kürzel löschen" + }, + "pinEntry": { + "enterPin": "PIN eingeben", + "showPin": "PIN anzeigen", + "hidePin": "PIN verbergen" + }, + "fileInfo": { + "title": "Dateiinfo", + "video": "Video", + "audio": "Audio", + "file": "Datei", + "advanced": "Erweitert", + "codec": "Codec", + "resolution": "Auflösung", + "bitrate": "Bitrate", + "frameRate": "Bildrate", + "aspectRatio": "Seitenverhältnis", + "profile": "Profil", + "bitDepth": "Farbtiefe", + "colorSpace": "Farbraum", + "colorRange": "Farbbereich", + "colorPrimaries": "Primärfarben", + "chromaSubsampling": "Chroma-Subsampling", + "channels": "Kanäle", + "path": "Pfad", + "size": "Größe", + "container": "Container", + "duration": "Dauer", + "optimizedForStreaming": "Für Streaming optimiert", + "has64bitOffsets": "64-Bit-Offsets" + }, + "mediaMenu": { + "markAsWatched": "Als gesehen markieren", + "markAsUnwatched": "Als ungesehen markieren", + "removeFromContinueWatching": "Aus ‚Weiterschauen‘ entfernen", + "goToSeries": "Zur Serie", + "goToSeason": "Zur Staffel", + "shufflePlay": "Zufallswiedergabe", + "fileInfo": "Dateiinfo" + }, + "tooltips": { + "shufflePlay": "Zufallswiedergabe", + "markAsWatched": "Als gesehen markieren", + "markAsUnwatched": "Als ungesehen markieren" + }, + "videoControls": { + "audioLabel": "Audio", + "subtitlesLabel": "Untertitel", + "resetToZero": "Auf 0 ms zurücksetzen", + "addTime": "+${amount}${unit}", + "minusTime": "-${amount}${unit}", + "playsLater": "${label} spielt später", + "playsEarlier": "${label} spielt früher", + "noOffset": "Kein Offset", + "letterbox": "Letterbox", + "fillScreen": "Bild füllen", + "stretch": "Strecken", + "lockRotation": "Rotation sperren", + "unlockRotation": "Rotation entsperren" + }, + "userStatus": { + "admin": "Admin", + "restricted": "Eingeschränkt", + "protected": "Geschützt" + }, + "messages": { + "markedAsWatched": "Als gesehen markiert", + "markedAsUnwatched": "Als ungesehen markiert", + "removedFromContinueWatching": "Aus ‚Weiterschauen‘ entfernt", + "errorLoading": "Fehler: ${error}", + "fileInfoNotAvailable": "Dateiinfo nicht verfügbar", + "errorLoadingFileInfo": "Fehler beim Laden der Dateiinfo: ${error}", + "errorLoadingSeries": "Fehler beim Laden der Serie", + "errorLoadingSeason": "Fehler beim Laden der Staffel", + "musicNotSupported": "Musikwiedergabe wird noch nicht unterstützt", + "logsCleared": "Protokolle gelöscht", + "logsCopied": "Protokolle in Zwischenablage kopiert", + "noLogsAvailable": "Keine Protokolle verfügbar", + "libraryScanning": "Scanne „${title}“...", + "libraryScanStarted": "Bibliotheksscan gestartet für „${title}“", + "libraryScanFailed": "Fehler beim Scannen der Bibliothek: ${error}", + "metadataRefreshing": "Metadaten werden aktualisiert für „${title}“...", + "metadataRefreshStarted": "Metadaten-Aktualisierung gestartet für „${title}“", + "metadataRefreshFailed": "Metadaten konnten nicht aktualisiert werden: ${error}", + "noPlexToken": "Kein Plex-Token gefunden. Bitte erneut anmelden.", + "logoutConfirm": "Abmeldung wirklich durchführen?", + "noSeasonsFound": "Keine Staffeln gefunden", + "noEpisodesFound": "Keine Episoden in der ersten Staffel gefunden", + "noEpisodesFoundGeneral": "Keine Episoden gefunden", + "noResultsFound": "Keine Ergebnisse gefunden", + "sleepTimerSet": "Sleep-Timer gesetzt auf ${label}", + "failedToSwitchProfile": "Profilwechsel zu ${displayName} fehlgeschlagen" + }, + "profile": { + "noUsersAvailable": "Keine Benutzer verfügbar" + }, + "subtitlingStyling": { + "stylingOptions": "Stiloptionen", + "fontSize": "Schriftgröße", + "textColor": "Textfarbe", + "borderSize": "Rahmengröße", + "borderColor": "Rahmenfarbe", + "backgroundOpacity": "Hintergrunddeckkraft", + "backgroundColor": "Hintergrundfarbe" + }, + "dialog": { + "confirmAction": "Aktion bestätigen", + "areYouSure": "Aktion wirklich ausführen?", + "cancel": "Abbrechen", + "playNow": "Jetzt abspielen" + }, + "discover": { + "title": "Entdecken", + "switchProfile": "Profil wechseln", + "switchServer": "Server wechseln", + "logout": "Abmelden", + "noContentAvailable": "Kein Inhalt verfügbar", + "addMediaToLibraries": "Medien zur Bibliothek hinzufügen", + "continueWatching": "Weiterschauen", + "recentlyAdded": "Kürzlich hinzugefügt", + "play": "Abspielen", + "resume": "Fortsetzen", + "playEpisode": "Spiele S${season}, E${episode}", + "resumeEpisode": "Fortsetzen S${season}, E${episode}", + "pause": "Pause", + "overview": "Übersicht", + "cast": "Besetzung", + "episodeCount": "${count} Episoden", + "watchedProgress": "${watched}/${total} gesehen", + "movie": "Film", + "tvShow": "Serie", + "minutesLeft": "${minutes} Min übrig" + }, + "errors": { + "searchFailed": "Suche fehlgeschlagen: ${error}", + "connectionTimeout": "Zeitüberschreitung beim Laden von ${context}", + "connectionFailed": "Verbindung zum Plex-Server fehlgeschlagen", + "failedToLoad": "Fehler beim Laden von ${context}: ${error}", + "noClientAvailable": "Kein Client verfügbar", + "authenticationFailed": "Authentifizierung fehlgeschlagen: ${error}", + "couldNotLaunchUrl": "Auth-URL konnte nicht geöffnet werden", + "pleaseEnterToken": "Bitte Token eingeben", + "invalidToken": "Ungültiges Token", + "failedToVerifyToken": "Token-Verifizierung fehlgeschlagen: ${error}", + "failedToSwitchProfile": "Profilwechsel zu ${displayName} fehlgeschlagen", + "connectionFailedGeneric": "Verbindung fehlgeschlagen" + }, + "libraries": { + "title": "Bibliotheken", + "scanLibraryFiles": "Bibliotheksdateien scannen", + "scanLibrary": "Bibliothek scannen", + "analyze": "Analysieren", + "analyzeLibrary": "Bibliothek analysieren", + "refreshMetadata": "Metadaten aktualisieren", + "emptyTrash": "Papierkorb leeren", + "emptyingTrash": "Papierkorb für „${title}“ wird geleert...", + "trashEmptied": "Papierkorb für „${title}“ geleert", + "failedToEmptyTrash": "Papierkorb konnte nicht geleert werden: ${error}", + "analyzing": "Analysiere „${title}“...", + "analysisStarted": "Analyse gestartet für „${title}“", + "failedToAnalyze": "Analyse der Bibliothek fehlgeschlagen: ${error}", + "noLibrariesFound": "Keine Bibliotheken gefunden", + "thisLibraryIsEmpty": "Diese Bibliothek ist leer", + "all": "Alle", + "clearAll": "Alle löschen", + "scanLibraryConfirm": "„${title}“ wirklich scannen?", + "analyzeLibraryConfirm": "„${title}“ wirklich analysieren?", + "refreshMetadataConfirm": "Metadaten für „${title}“ wirklich aktualisieren?", + "emptyTrashConfirm": "Papierkorb für „${title}“ wirklich leeren?", + "manageLibraries": "Bibliotheken verwalten", + "sort": "Sortieren", + "sortBy": "Sortieren nach", + "filters": "Filter", + "loadingLibraryWithCount": "Lade Bibliothek... (${count} Elemente geladen)", + "confirmActionMessage": "Aktion wirklich durchführen?", + "showLibrary": "Bibliothek anzeigen", + "hideLibrary": "Bibliothek ausblenden", + "libraryOptions": "Bibliotheksoptionen" + }, + "about": { + "title": "Über", + "openSourceLicenses": "Open-Source-Lizenzen", + "versionLabel": "Version ${version}", + "appDescription": "Ein schöner Plex-Client für Flutter", + "viewLicensesDescription": "Lizenzen von Drittanbieter-Bibliotheken anzeigen" + }, + "serverSelection": { + "connectingToServer": "Verbinde mit Server...", + "serverDebugCopied": "Server-Debugdaten kopiert", + "copyDebugData": "Debugdaten kopieren", + "noServersFound": "Keine Server gefunden", + "malformedServerData": "${count} Server mit fehlerhaften Daten gefunden. Keine gültigen Server verfügbar.", + "incompleteServerInfo": "Einige Serverinformationen sind unvollständig und wurden übersprungen. Plex.tv-Konto prüfen.", + "incompleteConnectionInfo": "Serververbindungsinformationen unvollständig. Bitte erneut versuchen.", + "malformedServerInfo": "Serverinformationen fehlerhaft: ${message}", + "networkConnectionFailed": "Netzwerkverbindung fehlgeschlagen. Internetverbindung prüfen und erneut versuchen.", + "authenticationFailed": "Authentifizierung fehlgeschlagen. Bitte erneut anmelden.", + "plexServiceUnavailable": "Plex-Dienst nicht verfügbar. Bitte später erneut versuchen.", + "failedToLoadServers": "Server konnten nicht geladen werden: ${error}" + }, + "hubDetail": { + "title": "Titel", + "releaseYear": "Erscheinungsjahr", + "dateAdded": "Hinzugefügt am", + "rating": "Bewertung", + "noItemsFound": "Keine Elemente gefunden" + }, + "logs": { + "title": "Protokolle", + "clearLogs": "Protokolle löschen", + "copyLogs": "Protokolle kopieren", + "exportLogs": "Protokolle exportieren", + "noLogsToShow": "Keine Protokolle zum Anzeigen", + "error": "Fehler:", + "stackTrace": "Stacktrace:" + }, + "licenses": { + "relatedPackages": "Verwandte Pakete", + "license": "Lizenz", + "licenseNumber": "Lizenz ${number}", + "licensesCount": "${count} Lizenzen" + }, + "navigation": { + "home": "Start", + "search": "Suche", + "libraries": "Bibliotheken", + "settings": "Einstellungen" + } +} From 831552d5b8ea8a55fc6c47c06e6abf41c937a3a1 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 14 Nov 2025 23:37:26 +0100 Subject: [PATCH 10/29] refactor: better log redaction --- lib/services/storage_service.dart | 11 +++ lib/utils/app_logger.dart | 64 ++---------- lib/utils/log_redaction_manager.dart | 143 +++++++++++++++++++++++++++ 3 files changed, 161 insertions(+), 57 deletions(-) create mode 100644 lib/utils/log_redaction_manager.dart diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index 1b1c9349..4c16d670 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -1,6 +1,9 @@ import 'dart:convert'; + import 'package:shared_preferences/shared_preferences.dart'; +import '../utils/log_redaction_manager.dart'; + class StorageService { static const String _keyServerUrl = 'server_url'; static const String _keyToken = 'token'; @@ -32,11 +35,16 @@ class StorageService { Future _init() async { _prefs = await SharedPreferences.getInstance(); + // Seed known values so logs can redact immediately on startup. + LogRedactionManager.registerServerUrl(getServerUrl()); + LogRedactionManager.registerToken(getToken()); + LogRedactionManager.registerToken(getPlexToken()); } // Server URL Future saveServerUrl(String url) async { await _prefs.setString(_keyServerUrl, url); + LogRedactionManager.registerServerUrl(url); } String? getServerUrl() { @@ -46,6 +54,7 @@ class StorageService { // Server Access Token Future saveToken(String token) async { await _prefs.setString(_keyToken, token); + LogRedactionManager.registerToken(token); } String? getToken() { @@ -64,6 +73,7 @@ class StorageService { // Plex.tv Token (for API access) Future savePlexToken(String token) async { await _prefs.setString(_keyPlexToken, token); + LogRedactionManager.registerToken(token); } String? getPlexToken() { @@ -127,6 +137,7 @@ class StorageService { _prefs.remove(_keyHomeUsersCache), _prefs.remove(_keyHomeUsersCacheExpiry), ]); + LogRedactionManager.clearTrackedValues(); } // Get all credentials as a map diff --git a/lib/utils/app_logger.dart b/lib/utils/app_logger.dart index 07c39cb6..9450fed4 100644 --- a/lib/utils/app_logger.dart +++ b/lib/utils/app_logger.dart @@ -1,72 +1,22 @@ import 'package:logger/logger.dart'; -/// Redacts sensitive information from log messages +import 'log_redaction_manager.dart'; + +/// Redacts sensitive information from log messages based on known values. String _redactSensitiveData(String message) { - String redacted = message; + var redacted = LogRedactionManager.redact(message); - // Redact Plex tokens (alphanumeric strings typically 20+ characters) - // Pattern: X-Plex-Token=... or token=... or accessToken=... or similar + // Fallbacks for sensitive fields we cannot track ahead of time. redacted = redacted.replaceAllMapped( - RegExp(r'([Tt]oken[=:]\s*)([A-Za-z0-9_-]{10,})', caseSensitive: false), + RegExp(r'([Aa]uthorization[=:]\s*)([^\s,]+)'), (match) => '${match.group(1)}[REDACTED]', ); - // Redact authorization headers redacted = redacted.replaceAllMapped( - RegExp( - r'([Aa]uthorization[=:]\s*)([A-Za-z0-9_\-\.]+)', - caseSensitive: false, - ), + RegExp(r'([Pp]assword[=:]\s*)([^\s&,;]+)'), (match) => '${match.group(1)}[REDACTED]', ); - // Redact API keys - redacted = redacted.replaceAllMapped( - RegExp(r'([Aa]pi[Kk]ey[=:]\s*)([A-Za-z0-9_-]{10,})', caseSensitive: false), - (match) => '${match.group(1)}[REDACTED]', - ); - - // Redact passwords - redacted = redacted.replaceAllMapped( - RegExp(r'([Pp]assword[=:]\s*)([^\s&,;]+)', caseSensitive: false), - (match) => '${match.group(1)}[REDACTED]', - ); - - // Redact full URLs with tokens in query parameters - redacted = redacted.replaceAllMapped( - RegExp( - r'(https?://[^\s]*[?&])([Xx]-[Pp]lex-[Tt]oken|token)=([A-Za-z0-9_-]+)', - ), - (match) => '${match.group(1)}${match.group(2)}=[REDACTED]', - ); - - // Redact IP addresses in dot notation (e.g., 192.168.1.100) - redacted = redacted.replaceAllMapped( - RegExp(r'\b(\d{1,3}\.)(\d{1,3}\.)(\d{1,3}\.)(\d{1,3})\b'), - (match) => '${match.group(1)}***.***.${match.group(4)}', - ); - - // Redact IP addresses in dash notation (e.g., 192-168-1-11) - redacted = redacted.replaceAllMapped( - RegExp(r'\b(\d{1,3}-)(\d{1,3}-)(\d{1,3}-)(\d{1,3})\b'), - (match) => '${match.group(1)}***-***-${match.group(4)}', - ); - - // Redact standalone token-like strings (20+ alphanumeric characters) - // Only if they appear in common token contexts - redacted = redacted.replaceAllMapped(RegExp(r'\b([A-Za-z0-9_-]{20,})\b'), ( - match, - ) { - final token = match.group(1)!; - // Only redact if it looks like a token (mixed case or contains hyphens/underscores) - if (token.contains(RegExp(r'[A-Z]')) && token.contains(RegExp(r'[a-z]')) || - token.contains('_') || - token.contains('-')) { - return '[REDACTED_TOKEN]'; - } - return token; - }); - return redacted; } diff --git a/lib/utils/log_redaction_manager.dart b/lib/utils/log_redaction_manager.dart new file mode 100644 index 00000000..f0aff940 --- /dev/null +++ b/lib/utils/log_redaction_manager.dart @@ -0,0 +1,143 @@ +class LogRedactionManager { + static final Set _tokens = {}; + static final Set _urls = {}; + static final Set _customValues = {}; + static final RegExp _ipv4Pattern = + RegExp(r'\b(\d{1,3})([.-])(\d{1,3})\2(\d{1,3})\2(\d{1,3})\b'); + static final RegExp _ipv4HostPattern = + RegExp(r'^\d{1,3}([.-]\d{1,3}){3}$'); + + /// Register a server access token or Plex.tv token for redaction. + static void registerToken(String? token) { + final normalized = _normalize(token); + if (normalized == null) return; + + _tokens.add(normalized); + + // Tokens often appear URL encoded in query params. + final encoded = Uri.encodeQueryComponent(normalized); + if (encoded != normalized) { + _tokens.add(encoded); + } + } + + /// Register the server/base URL currently in use. + static void registerServerUrl(String? url) { + final normalized = _normalize(url); + if (normalized == null) return; + + final uri = Uri.tryParse(normalized); + final host = uri?.host; + if (host != null && host.isNotEmpty && _isIpv4Like(host)) { + // Do not register full IP-based URLs; regex redaction handles them. + return; + } + + if (host == null && _isIpv4Like(normalized)) { + return; + } + + final strippedSlash = normalized.endsWith('/') + ? normalized.substring(0, normalized.length - 1) + : normalized; + + if (strippedSlash.isNotEmpty) { + _urls.add(strippedSlash); + _urls.add('$strippedSlash/'); // Include trailing slash variant. + } + + // Capture origin and host-level strings as well to cover most cases. + if (uri != null && uri.host.isNotEmpty) { + final origin = + '${uri.scheme.isEmpty ? 'https' : uri.scheme}://${uri.host}${uri.hasPort ? ':${uri.port}' : ''}'; + _urls.add(origin); + if (origin.endsWith('/')) { + _urls.add(origin.substring(0, origin.length - 1)); + } + } + } + + /// Register other sensitive values that need redaction. + static void registerCustomValue(String? value) { + final normalized = _normalize(value); + if (normalized == null) return; + _customValues.add(normalized); + } + + /// Reset any tracked sensitive values (e.g., on logout). + static void clearTrackedValues() { + _tokens.clear(); + _urls.clear(); + _customValues.clear(); + } + + /// Redact known sensitive values from the provided message. + static String redact(String message) { + var redacted = message; + + redacted = redacted.replaceAllMapped( + _ipv4Pattern, + (match) => _maskIpv4(match.group(1)!, match.group(2)!, match.group(5)!), + ); + + for (final url in _urls) { + redacted = redacted.replaceAll(url, _maskUrlPreview(url)); + } + + for (final token in _tokens) { + redacted = redacted.replaceAll(token, '[REDACTED_TOKEN]'); + } + + for (final custom in _customValues) { + redacted = redacted.replaceAll(custom, '[REDACTED]'); + } + + return redacted; + } + + static String? _normalize(String? value) { + if (value == null) return null; + final trimmed = value.trim(); + if (trimmed.isEmpty) return null; + return trimmed; + } + + static bool _isIpv4Like(String value) { + return _ipv4HostPattern.hasMatch(value); + } + + static String _maskIpv4(String first, String separator, String last) { + return '$first$separator' + 'x$separator' + 'x$separator' + '$last'; + } + + static String _maskUrlPreview(String url) { + const startPreviewLength = 12; + const endPreviewLength = 8; + + if (url.isEmpty) { + return '[REDACTED_URL]'; + } + + if (url.length <= 4) { + return '[REDACTED_URL]'; + } + + final startLength = url.length <= startPreviewLength + ? (url.length / 2).ceil() + : startPreviewLength; + final remainingForEnd = url.length - startLength; + final endLength = + remainingForEnd <= endPreviewLength ? remainingForEnd : endPreviewLength; + + final start = url.substring(0, startLength); + if (endLength <= 0) { + return '$start...[REDACTED_URL]'; + } + + final end = url.substring(url.length - endLength); + return '$start...[REDACTED_URL]...$end'; + } +} From e6b699c9760522f6f9cbe060fd2033dff998f0df Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 15 Nov 2025 00:42:45 +0100 Subject: [PATCH 11/29] fix: better connection selection --- ios/Podfile.lock | 6 + lib/client/plex_client.dart | 64 ++- .../endpoint_failover_interceptor.dart | 177 ++++++++ lib/services/plex_auth_service.dart | 421 +++++++++++++++--- lib/services/server_connection_service.dart | 307 ++++++++++--- macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 24 + pubspec.yaml | 1 + .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 10 files changed, 884 insertions(+), 122 deletions(-) create mode 100644 lib/network/endpoint_failover_interceptor.dart diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 70cb3503..7f1d716c 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,4 +1,6 @@ PODS: + - connectivity_plus (0.0.1): + - Flutter - Flutter (1.0.0) - media_kit_libs_ios_video (1.0.4): - Flutter @@ -26,6 +28,7 @@ PODS: - Flutter DEPENDENCIES: + - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) - Flutter (from `Flutter`) - media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`) - media_kit_video (from `.symlinks/plugins/media_kit_video/ios`) @@ -39,6 +42,8 @@ DEPENDENCIES: - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`) EXTERNAL SOURCES: + connectivity_plus: + :path: ".symlinks/plugins/connectivity_plus/ios" Flutter: :path: Flutter media_kit_libs_ios_video: @@ -63,6 +68,7 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/wakelock_plus/ios" SPEC CHECKSUMS: + connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 media_kit_libs_ios_video: 5a18affdb97d1f5d466dc79988b13eff6c5e2854 media_kit_video: 6235abf1d299037d23692ad47119b89e05346b23 diff --git a/lib/client/plex_client.dart b/lib/client/plex_client.dart index 83a6a930..b01e62f3 100644 --- a/lib/client/plex_client.dart +++ b/lib/client/plex_client.dart @@ -12,7 +12,9 @@ import '../models/plex_media_version.dart'; import '../models/plex_metadata.dart'; import '../models/plex_sort.dart'; import '../models/plex_video_playback_data.dart'; +import '../network/endpoint_failover_interceptor.dart'; import '../utils/app_logger.dart'; +import '../utils/log_redaction_manager.dart'; /// Result of testing a connection, including success status and latency class ConnectionTestResult { @@ -25,6 +27,8 @@ class ConnectionTestResult { class PlexClient { PlexConfig config; late final Dio _dio; + final EndpointFailoverManager? _endpointManager; + final Future Function(String newBaseUrl)? _onEndpointChanged; /// Custom response decoder that handles malformed UTF-8 gracefully static String _lenientUtf8Decoder( @@ -35,7 +39,18 @@ class PlexClient { return utf8.decode(responseBytes, allowMalformed: true); } - PlexClient(this.config) { + PlexClient( + this.config, { + List? prioritizedEndpoints, + Future Function(String newBaseUrl)? onEndpointChanged, + }) : _endpointManager = (prioritizedEndpoints != null && + prioritizedEndpoints.isNotEmpty) + ? EndpointFailoverManager(prioritizedEndpoints) + : null, + _onEndpointChanged = onEndpointChanged { + LogRedactionManager.registerServerUrl(config.baseUrl); + LogRedactionManager.registerToken(config.token); + _dio = Dio( BaseOptions( baseUrl: config.baseUrl, @@ -59,6 +74,16 @@ class PlexClient { responseHeader: false, ), ); + + if (_endpointManager != null) { + _dio.interceptors.add( + EndpointFailoverInterceptor( + dio: _dio, + endpointManager: _endpointManager, + onEndpointSwitch: _handleEndpointSwitch, + ), + ); + } } /// Update the token used by this client @@ -66,9 +91,31 @@ class PlexClient { // Update both the Dio headers and the config to ensure consistency _dio.options.headers['X-Plex-Token'] = newToken; config = config.copyWith(token: newToken); + LogRedactionManager.registerToken(newToken); appLogger.d('PlexClient token updated (headers and config)'); } + /// Update endpoint priority list and optionally hop to the new best endpoint. + Future updateEndpointPreferences( + List prioritizedEndpoints, { + bool switchToFirst = false, + }) async { + if (_endpointManager == null || prioritizedEndpoints.isEmpty) { + return; + } + + final targetBaseUrl = + switchToFirst ? prioritizedEndpoints.first : config.baseUrl; + _endpointManager.reset( + prioritizedEndpoints, + currentBaseUrl: targetBaseUrl, + ); + + if (switchToFirst && targetBaseUrl != config.baseUrl) { + await _handleEndpointSwitch(targetBaseUrl); + } + } + /// Test connection to server Future testConnection() async { try { @@ -1209,4 +1256,19 @@ class PlexClient { Future analyzeLibrary(String sectionId) async { await _dio.get('/library/sections/$sectionId/analyze'); } + + Future _handleEndpointSwitch(String newBaseUrl) async { + if (config.baseUrl == newBaseUrl) { + return; + } + + appLogger.i('Applying Plex endpoint switch', error: newBaseUrl); + _dio.options.baseUrl = newBaseUrl; + config = config.copyWith(baseUrl: newBaseUrl); + LogRedactionManager.registerServerUrl(newBaseUrl); + + if (_onEndpointChanged != null) { + await _onEndpointChanged(newBaseUrl); + } + } } diff --git a/lib/network/endpoint_failover_interceptor.dart b/lib/network/endpoint_failover_interceptor.dart new file mode 100644 index 00000000..71c683da --- /dev/null +++ b/lib/network/endpoint_failover_interceptor.dart @@ -0,0 +1,177 @@ +import 'package:dio/dio.dart'; + +import '../utils/app_logger.dart'; + +/// Maintains the list of endpoints we can cycle through when one fails. +class EndpointFailoverManager { + EndpointFailoverManager(List urls) { + _setEndpoints(urls); + } + + late List _endpoints; + int _currentIndex = 0; + + List get endpoints => List.unmodifiable(_endpoints); + + String get current => _endpoints[_currentIndex]; + + bool get hasFallback => _currentIndex < _endpoints.length - 1; + + /// Move to the next endpoint, returning its URL or null if exhausted. + String? moveToNext() { + if (!hasFallback) return null; + _currentIndex++; + return _endpoints[_currentIndex]; + } + + /// Replace the endpoint list and optionally set the active endpoint. + void reset( + List urls, { + String? currentBaseUrl, + }) { + _setEndpoints(urls); + if (currentBaseUrl != null) { + final index = _endpoints.indexOf(currentBaseUrl); + _currentIndex = index >= 0 ? index : 0; + } else { + _currentIndex = 0; + } + } + + void _setEndpoints(List urls) { + final sanitized = []; + final seen = {}; + for (final url in urls) { + if (url.isEmpty || seen.contains(url)) continue; + seen.add(url); + sanitized.add(url); + } + if (sanitized.isEmpty) { + throw ArgumentError('At least one endpoint is required'); + } + _endpoints = sanitized; + _currentIndex = _currentIndex.clamp(0, _endpoints.length - 1); + } +} + +/// Dio interceptor that retries failed requests on the next available endpoint. +class EndpointFailoverInterceptor extends Interceptor { + EndpointFailoverInterceptor({ + required Dio dio, + required this.endpointManager, + required Future Function(String newBaseUrl) onEndpointSwitch, + }) : _dio = dio, + _onEndpointSwitch = onEndpointSwitch; + + final Dio _dio; + final EndpointFailoverManager endpointManager; + final Future Function(String newBaseUrl) _onEndpointSwitch; + bool _isSwitching = false; + + @override + void onError(DioException err, ErrorInterceptorHandler handler) async { + if (_isSwitching || + !_shouldAttemptFailover(err) || + !endpointManager.hasFallback) { + handler.next(err); + return; + } + + final failedEndpoint = endpointManager.current; + appLogger.w( + 'Endpoint request failed, evaluating failover', + error: { + 'endpoint': failedEndpoint, + 'type': err.type.name, + 'statusCode': err.response?.statusCode, + }, + stackTrace: err.stackTrace, + ); + + final nextBaseUrl = endpointManager.moveToNext(); + if (nextBaseUrl == null) { + appLogger.w( + 'Endpoint failure but no fallback endpoints remain', + error: {'failedEndpoint': failedEndpoint}, + ); + handler.next(err); + return; + } + + _isSwitching = true; + try { + appLogger.i( + 'Switching Plex endpoint after request failure', + error: { + 'from': failedEndpoint, + 'to': nextBaseUrl, + 'path': err.requestOptions.path, + }, + ); + await _onEndpointSwitch(nextBaseUrl); + final response = await _retryRequest(err.requestOptions); + appLogger.i( + 'Endpoint failover retry succeeded', + error: {'newEndpoint': nextBaseUrl}, + ); + handler.resolve(response); + } on DioException catch (dioError) { + appLogger.w( + 'Endpoint failover retry failed', + error: { + 'newEndpoint': nextBaseUrl, + 'type': dioError.type.name, + 'statusCode': dioError.response?.statusCode, + }, + stackTrace: dioError.stackTrace, + ); + handler.next(dioError); + } catch (_) { + handler.next(err); + } finally { + _isSwitching = false; + } + } + + bool _shouldAttemptFailover(DioException error) { + if (error.type == DioExceptionType.connectionTimeout || + error.type == DioExceptionType.receiveTimeout || + error.type == DioExceptionType.sendTimeout || + error.type == DioExceptionType.connectionError) { + return true; + } + + if (error.type == DioExceptionType.badResponse) { + final statusCode = error.response?.statusCode ?? 0; + return statusCode >= 500; + } + + return false; + } + + Future> _retryRequest(RequestOptions requestOptions) { + final options = Options( + method: requestOptions.method, + headers: requestOptions.headers, + responseType: requestOptions.responseType, + contentType: requestOptions.contentType, + followRedirects: requestOptions.followRedirects, + receiveDataWhenStatusError: requestOptions.receiveDataWhenStatusError, + validateStatus: requestOptions.validateStatus, + sendTimeout: requestOptions.sendTimeout, + receiveTimeout: requestOptions.receiveTimeout, + extra: requestOptions.extra, + listFormat: requestOptions.listFormat, + ); + + return _dio.request( + requestOptions.path, + data: requestOptions.data, + queryParameters: requestOptions.queryParameters, + options: options, + cancelToken: requestOptions.cancelToken, + onSendProgress: requestOptions.onSendProgress, + onReceiveProgress: requestOptions.onReceiveProgress, + ); + } +} diff --git a/lib/services/plex_auth_service.dart b/lib/services/plex_auth_service.dart index 7c6ec57c..a380cc9c 100644 --- a/lib/services/plex_auth_service.dart +++ b/lib/services/plex_auth_service.dart @@ -6,6 +6,7 @@ import '../client/plex_client.dart'; import '../models/plex_user_profile.dart'; import '../models/plex_home.dart'; import '../models/user_switch_response.dart'; +import '../utils/app_logger.dart'; class PlexAuthService { static const String _appName = 'Plezy'; @@ -240,8 +241,14 @@ class _ConnectionCandidate { final PlexConnection connection; final String url; final bool isPlexDirectUri; + final bool isHttps; - _ConnectionCandidate(this.connection, this.url, this.isPlexDirectUri); + _ConnectionCandidate( + this.connection, + this.url, + this.isPlexDirectUri, + this.isHttps, + ); } /// Represents a Plex Media Server @@ -391,74 +398,116 @@ class PlexServer { /// Priority: local > remote > relay, then HTTPS > HTTP, then lowest latency /// Tests both plex.direct URI and direct IP for each connection /// HTTPS connections are tested first, with HTTP as fallback - Stream findBestWorkingConnection() async* { - if (connections.isEmpty) return; + Stream findBestWorkingConnection({ + String? preferredUri, + }) async* { + if (connections.isEmpty) { + appLogger.w('No connections available for server discovery'); + return; + } - // Create candidates: test both uri and directUrl for each connection - // Separate HTTPS and HTTP candidates to prioritize HTTPS first - final httpsCandidates = <_ConnectionCandidate>[]; - final httpCandidates = <_ConnectionCandidate>[]; + const preferredTimeout = Duration(seconds: 2); + const raceTimeout = Duration(seconds: 4); - for (final connection in connections) { - final uriCandidate = _ConnectionCandidate( - connection, - connection.uri, - true, - ); - final directCandidate = _ConnectionCandidate( - connection, - connection.directUrl, - false, - ); + final candidates = _buildPrioritizedCandidates(); + if (candidates.isEmpty) { + appLogger.w('No connection candidates generated for server discovery'); + return; + } - if (connection.protocol == 'https') { - httpsCandidates.add(uriCandidate); - httpsCandidates.add(directCandidate); - } else { - httpCandidates.add(uriCandidate); - httpCandidates.add(directCandidate); + final totalCandidates = candidates.length; + appLogger.d( + 'Starting server connection discovery', + error: { + 'preferred': preferredUri, + 'candidateCount': totalCandidates, + }, + ); + + _ConnectionCandidate? firstCandidate; + + // Fast-path: if we have a cached working URI, probe it with a short timeout + if (preferredUri != null) { + final cachedCandidate = _candidateForUrl(preferredUri); + if (cachedCandidate != null) { + appLogger.d( + 'Testing cached endpoint before running full race', + error: {'uri': preferredUri}, + ); + final result = await PlexClient.testConnectionWithLatency( + cachedCandidate.url, + accessToken, + timeout: preferredTimeout, + ); + + if (result.success) { + appLogger.i( + 'Cached endpoint succeeded, using immediately', + error: {'uri': preferredUri}, + ); + firstCandidate = cachedCandidate; + } else { + appLogger.w( + 'Cached endpoint failed, falling back to candidate race', + error: {'uri': preferredUri}, + ); + } } } - // Combine candidates with HTTPS first, then HTTP - final candidates = [...httpsCandidates, ...httpCandidates]; - - // Phase 1: Race to find first working connection - final completer = Completer<_ConnectionCandidate?>(); - _ConnectionCandidate? firstCandidate; - int completedTests = 0; - - // Start testing all candidates simultaneously - for (final candidate in candidates) { - PlexClient.testConnectionWithLatency(candidate.url, accessToken).then(( - result, - ) { - completedTests++; - - // If this is the first successful connection, emit it immediately - if (result.success && !completer.isCompleted) { - completer.complete(candidate); - } - - // If all tests complete without success, complete with null - if (completedTests == candidates.length && !completer.isCompleted) { - completer.complete(null); - } - }); - } - - // Wait for and emit the first successful connection - firstCandidate = await completer.future; + // If no cached candidate or it failed, race candidates to find first success if (firstCandidate == null) { - return; // No working connections found + final completer = Completer<_ConnectionCandidate?>(); + int completedTests = 0; + + appLogger.d( + 'Running connection race to find first working endpoint', + error: {'candidateCount': totalCandidates}, + ); + + for (final candidate in candidates) { + PlexClient + .testConnectionWithLatency( + candidate.url, + accessToken, + timeout: raceTimeout, + ) + .then((result) { + completedTests++; + + if (result.success && !completer.isCompleted) { + completer.complete(candidate); + } + + if (completedTests == candidates.length && !completer.isCompleted) { + completer.complete(null); + } + }); + } + + firstCandidate = await completer.future; + if (firstCandidate == null) { + appLogger.e('No working server connections after race'); + return; // No working connections found + } + appLogger.i( + 'Connection race found first working endpoint', + error: { + 'uri': firstCandidate.url, + 'type': firstCandidate.connection.displayType, + }, + ); } - // Update the connection object to use the working URL final firstConnection = _updateConnectionUrl( firstCandidate.connection, firstCandidate.url, ); yield firstConnection; + appLogger.d( + 'Emitted first working connection, continuing latency tests in background', + error: {'uri': firstConnection.uri}, + ); // Phase 2: Continue testing in background to find best connection // Test each candidate 2-3 times and average the latency @@ -480,20 +529,39 @@ class PlexServer { // If no connections succeeded, we're done if (candidateResults.isEmpty) { + appLogger.w('Latency sweep found no additional working endpoints'); return; } + appLogger.d( + 'Completed latency sweep for server connections', + error: {'successfulCandidates': candidateResults.length}, + ); + // Find the best connection considering priority, latency, and URL type final bestCandidate = _selectBestCandidateWithLatency(candidateResults); // Emit the best connection if it's different from the first one if (bestCandidate != null) { + final upgradedCandidate = + await _upgradeCandidateToHttpsIfPossible(bestCandidate) ?? + bestCandidate; + final bestConnection = _updateConnectionUrl( - bestCandidate.connection, - bestCandidate.url, + upgradedCandidate.connection, + upgradedCandidate.url, ); if (bestConnection.uri != firstConnection.uri) { + appLogger.i( + 'Latency sweep selected better endpoint', + error: {'uri': bestConnection.uri}, + ); yield bestConnection; + } else { + appLogger.d( + 'Latency sweep confirmed initial endpoint is optimal', + error: {'uri': bestConnection.uri}, + ); } } } @@ -517,6 +585,234 @@ class PlexServer { ); } + _ConnectionCandidate? _candidateForUrl(String url) { + for (final connection in connections) { + final httpUrl = connection.httpDirectUrl; + if (httpUrl == url) { + return _ConnectionCandidate(connection, httpUrl, false, false); + } + + final uri = connection.uri; + if (uri == url) { + final isHttps = uri.startsWith('https://'); + final parsedHost = Uri.tryParse(uri)?.host ?? ''; + final isPlexDirect = + parsedHost.toLowerCase().contains('plex.direct'); + return _ConnectionCandidate(connection, uri, isPlexDirect, isHttps); + } + } + return null; + } + + List<_ConnectionCandidate> _buildPrioritizedCandidates({ + Set? excludeUrls, + }) { + final seen = {}; + if (excludeUrls != null) { + seen.addAll(excludeUrls); + } + + final httpsLocal = <_ConnectionCandidate>[]; + final httpsRemote = <_ConnectionCandidate>[]; + final httpsRelay = <_ConnectionCandidate>[]; + final httpLocal = <_ConnectionCandidate>[]; + final httpRemote = <_ConnectionCandidate>[]; + final httpRelay = <_ConnectionCandidate>[]; + + List<_ConnectionCandidate> bucketFor( + PlexConnection connection, + bool isHttps, + ) { + if (isHttps) { + if (connection.relay) return httpsRelay; + if (connection.local) return httpsLocal; + return httpsRemote; + } else { + if (connection.relay) return httpRelay; + if (connection.local) return httpLocal; + return httpRemote; + } + } + + void addCandidate( + PlexConnection connection, + String url, + bool isPlexDirectUri, + bool isHttps, + ) { + if (url.isEmpty || seen.contains(url)) { + return; + } + seen.add(url); + bucketFor(connection, isHttps).add( + _ConnectionCandidate(connection, url, isPlexDirectUri, isHttps), + ); + } + + for (final connection in connections) { + addCandidate( + connection, + connection.httpDirectUrl, + false, + false, + ); + } + + return [ + ...httpsLocal, + ...httpsRemote, + ...httpsRelay, + ...httpLocal, + ...httpRemote, + ...httpRelay, + ]; + } + + List prioritizedEndpointUrls({String? preferredFirst}) { + final urls = []; + final exclude = {}; + + if (preferredFirst != null && preferredFirst.isNotEmpty) { + urls.add(preferredFirst); + exclude.add(preferredFirst); + } + + final candidates = _buildPrioritizedCandidates(excludeUrls: exclude); + urls.addAll(candidates.map((candidate) => candidate.url)); + return urls; + } + + Future<_ConnectionCandidate?> _upgradeCandidateToHttpsIfPossible( + _ConnectionCandidate candidate, + ) async { + final currentUrl = candidate.url; + if (currentUrl.startsWith('https://')) { + return null; + } + + late final String httpsUrl; + bool resultingIsPlexDirect = candidate.isPlexDirectUri; + + if (candidate.isPlexDirectUri) { + if (!currentUrl.startsWith('http://')) { + return null; + } + httpsUrl = currentUrl.replaceFirst('http://', 'https://'); + } else { + // Raw IP endpoints can't present HTTPS certificates—prefer their plex.direct alias. + final plexDirectUri = candidate.connection.uri; + if (plexDirectUri.isEmpty) { + return null; + } + + if (plexDirectUri.startsWith('https://')) { + httpsUrl = plexDirectUri; + } else if (plexDirectUri.startsWith('http://')) { + httpsUrl = plexDirectUri.replaceFirst('http://', 'https://'); + } else { + return null; + } + + final upgradedHost = Uri.tryParse(httpsUrl)?.host; + if (upgradedHost == null || + !upgradedHost.toLowerCase().endsWith('.plex.direct')) { + appLogger.d( + 'Skipping HTTPS upgrade for raw IP candidate: no plex.direct alias available', + error: {'candidate': currentUrl, 'target': httpsUrl}, + ); + return null; + } + resultingIsPlexDirect = true; + } + + if (httpsUrl == currentUrl) { + return null; + } + + appLogger.d( + 'Attempting HTTPS upgrade for candidate endpoint', + error: {'from': currentUrl, 'to': httpsUrl}, + ); + + final result = await PlexClient.testConnectionWithLatency( + httpsUrl, + accessToken, + timeout: const Duration(seconds: 4), + ); + + if (!result.success) { + appLogger.w( + 'HTTPS upgrade failed, staying on HTTP candidate', + error: {'url': currentUrl}, + ); + return null; + } + + appLogger.i( + 'HTTPS upgrade succeeded for candidate endpoint', + error: {'httpsUrl': httpsUrl}, + ); + + final httpsConnection = PlexConnection( + protocol: 'https', + address: candidate.connection.address, + port: candidate.connection.port, + uri: httpsUrl, + local: candidate.connection.local, + relay: candidate.connection.relay, + ipv6: candidate.connection.ipv6, + ); + + return _ConnectionCandidate( + httpsConnection, + httpsUrl, + resultingIsPlexDirect, + true, + ); + } + + Future upgradeConnectionToHttps( + PlexConnection current, + ) async { + if (current.uri.startsWith('https://')) { + return current; + } + + final baseConnection = _findMatchingBaseConnection(current); + if (baseConnection == null) { + return null; + } + + final candidate = _ConnectionCandidate( + baseConnection, + current.uri, + current.uri.contains('.plex.direct'), + current.uri.startsWith('https://'), + ); + final upgradedCandidate = + await _upgradeCandidateToHttpsIfPossible(candidate); + if (upgradedCandidate == null) { + return null; + } + return _updateConnectionUrl( + upgradedCandidate.connection, + upgradedCandidate.url, + ); + } + + PlexConnection? _findMatchingBaseConnection(PlexConnection connection) { + for (final base in connections) { + final sameAddress = base.address == connection.address; + final samePort = base.port == connection.port; + final sameLocal = base.local == connection.local; + final sameRelay = base.relay == connection.relay; + if (sameAddress && samePort && sameLocal && sameRelay) { + return base; + } + } + return null; + } + /// Select the best candidate considering priority, latency, and URL type preference _ConnectionCandidate? _selectBestCandidateWithLatency( Map<_ConnectionCandidate, ConnectionTestResult> results, @@ -550,8 +846,8 @@ class PlexServer { if (latencyCompare != 0) return latencyCompare; // If latencies are equal, prefer HTTPS over HTTP - final aIsHttps = a.key.connection.protocol == 'https'; - final bIsHttps = b.key.connection.protocol == 'https'; + final aIsHttps = a.key.isHttps; + final bIsHttps = b.key.isHttps; if (aIsHttps && !bIsHttps) return -1; if (!aIsHttps && bIsHttps) return 1; @@ -641,6 +937,13 @@ class PlexConnection { /// This bypasses plex.direct DNS and connects directly to the IP String get directUrl => '$protocol://$address:$port'; + /// Always return an HTTP URL that points directly at the IP/port combo. + String get httpDirectUrl { + final needsBrackets = address.contains(':') && !address.startsWith('['); + final safeAddress = needsBrackets ? '[$address]' : address; + return 'http://$safeAddress:$port'; + } + String get displayType { if (relay) return 'Relay'; if (local) return 'Local'; diff --git a/lib/services/server_connection_service.dart b/lib/services/server_connection_service.dart index 32569b25..5a3dbea2 100644 --- a/lib/services/server_connection_service.dart +++ b/lib/services/server_connection_service.dart @@ -1,3 +1,7 @@ +import 'dart:async'; + +import 'package:connectivity_plus/connectivity_plus.dart'; + import 'plex_auth_service.dart'; import 'storage_service.dart'; import '../client/plex_client.dart'; @@ -19,6 +23,12 @@ class ServerConnectionResult { /// Service for handling optimized server connections /// Implements fast-first connection with background optimization class ServerConnectionService { + static StreamSubscription>? + _connectivitySubscription; + static Future? _activeOptimization; + static PlexServer? _activeServer; + static PlexClient? _activeClient; + /// Connect to a Plex server with optimized connection testing /// /// Returns immediately with first working connection, then continues @@ -39,86 +49,115 @@ class ServerConnectionService { bool fetchUserProfile = false, void Function(String message)? onProgress, }) async { - PlexConnection? firstConnection; final storage = await StorageService.getInstance(); + final connectionStream = + server.findBestWorkingConnection().asBroadcastStream(); + PlexClient? client; + + final optimizationSubscription = connectionStream.skip(1).listen( + (connection) async { + await _handleOptimizedConnection( + connection: connection, + storage: storage, + server: server, + client: client, + reason: 'initial_latency_sweep', + ); + }, + onError: (error, stackTrace) { + appLogger.w( + 'Background connection optimization error', + error: error, + stackTrace: stackTrace, + ); + }, + ); + try { - // Listen to the connection stream for progressive connection testing - await for (final connection in server.findBestWorkingConnection()) { - if (firstConnection == null) { - // First emission - use this connection immediately - firstConnection = connection; + final connection = await connectionStream.first; - if (onProgress != null) { - onProgress('Connected to ${connection.displayType} endpoint'); - } + if (onProgress != null) { + onProgress('Connected to ${connection.displayType} endpoint'); + } - // Save server information to storage - await storage.saveServerData(server.toJson()); - await storage.saveServerUrl(connection.uri); - await storage.saveServerAccessToken(server.accessToken); + // Save server information to storage + await storage.saveServerData(server.toJson()); + await storage.saveServerUrl(connection.uri); + await storage.saveServerAccessToken(server.accessToken); - // Save plex token if provided - if (plexToken != null) { - await storage.savePlexToken(plexToken); - } + // Save plex token if provided + if (plexToken != null) { + await storage.savePlexToken(plexToken); + } - // Create client with working connection - final config = await PlexConfig.create( - baseUrl: connection.uri, - token: server.accessToken, - clientIdentifier: clientIdentifier, + // Create client with working connection + final prioritizedEndpoints = server.prioritizedEndpointUrls( + preferredFirst: connection.uri, + ); + final config = await PlexConfig.create( + baseUrl: connection.uri, + token: server.accessToken, + clientIdentifier: clientIdentifier, + ); + client = PlexClient( + config, + prioritizedEndpoints: prioritizedEndpoints, + onEndpointChanged: (newUrl) async { + await storage.saveServerUrl(newUrl); + appLogger.i( + 'Updated stored server URL after failover', + error: newUrl, ); - final client = PlexClient(config); + }, + ); - // Verify server is accessible if requested - if (verifyServer) { - try { - await client.getServerIdentity(); - } catch (e) { - appLogger.w('Server identity verification failed', error: e); - await storage.clearCredentials(); - return ServerConnectionResult( - error: 'Server is not accessible: $e', - ); - } - } - - // Fetch user profile if requested - PlexUserProfile? userProfile; - if (fetchUserProfile && plexToken != null) { - userProfile = await _fetchUserProfile(plexToken); - } - - // Return success result - // Note: Stream continues in background to find better connection + // Verify server is accessible if requested + if (verifyServer) { + try { + await client.getServerIdentity(); + } catch (e) { + await optimizationSubscription.cancel(); + appLogger.w('Server identity verification failed', error: e); + await storage.clearCredentials(); return ServerConnectionResult( - client: client, - userProfile: userProfile, - ); - } else { - // Second emission - better connection found - // Update stored connection seamlessly for future app launches - await storage.saveServerUrl(connection.uri); - appLogger.d( - 'Switched to better connection: ${connection.displayType} (${connection.uri})', + error: 'Server is not accessible: $e', ); } } - // Handle case where no connections were found - if (firstConnection == null) { - return ServerConnectionResult( - error: 'No working connections found for this server', - ); + // Fetch user profile if requested + PlexUserProfile? userProfile; + if (fetchUserProfile && plexToken != null) { + userProfile = await _fetchUserProfile(plexToken); } - // Should never reach here due to return in the stream loop + // Return success result while optimization continues in background + _activeServer = server; + _activeClient = client; + _startConnectivityMonitoring(server); + return ServerConnectionResult( - error: 'Unexpected error in connection flow', + client: client, + userProfile: userProfile, + ); + } on StateError catch (e, stackTrace) { + await optimizationSubscription.cancel(); + appLogger.e( + 'No working connections found for this server', + error: e, + stackTrace: stackTrace, + ); + return ServerConnectionResult( + error: 'No working connections found for this server', + ); + } catch (e, stackTrace) { + await optimizationSubscription.cancel(); + appLogger.e( + 'Error connecting to server', + error: e, + stackTrace: stackTrace, ); - } catch (e) { - appLogger.e('Error connecting to server', error: e); return ServerConnectionResult(error: 'Connection failed: $e'); } } @@ -148,4 +187,148 @@ class ServerConnectionService { return null; } } + + static void _startConnectivityMonitoring(PlexServer server) { + _connectivitySubscription?.cancel(); + final connectivity = Connectivity(); + _connectivitySubscription = connectivity.onConnectivityChanged.listen( + (results) { + final status = + results.isNotEmpty ? results.first : ConnectivityResult.none; + if (status == ConnectivityResult.none) { + appLogger.w( + 'Connectivity lost, pausing optimization until network returns', + ); + return; + } + appLogger.d( + 'Connectivity change detected, triggering endpoint optimization', + error: { + 'status': status.name, + 'interfaces': results.map((r) => r.name).toList(), + }, + ); + _activeServer = server; + _triggerReoptimization(reason: 'connectivity:${status.name}'); + }, + onError: (error, stackTrace) { + appLogger.w( + 'Connectivity listener error', + error: error, + stackTrace: stackTrace, + ); + }, + ); + } + + static void _triggerReoptimization({required String reason}) { + if (_activeServer == null) { + appLogger.d( + 'Optimization trigger ignored because there is no active server', + error: {'reason': reason}, + ); + return; + } + + if (_activeOptimization != null) { + appLogger.d( + 'Optimization already running, skipping new trigger', + error: {'reason': reason}, + ); + return; + } + + _activeOptimization = _runOptimization( + server: _activeServer!, + client: _activeClient, + reason: reason, + ).whenComplete(() { + _activeOptimization = null; + }); + } + + static Future _runOptimization({ + required PlexServer server, + required PlexClient? client, + required String reason, + }) async { + final storage = await StorageService.getInstance(); + try { + appLogger.d( + 'Starting background connection optimization run', + error: {'reason': reason}, + ); + await for (final connection in server.findBestWorkingConnection()) { + await _handleOptimizedConnection( + connection: connection, + storage: storage, + server: server, + client: client, + reason: reason, + ); + } + } catch (e, stackTrace) { + appLogger.w( + 'Background connection optimization failed', + error: e, + stackTrace: stackTrace, + ); + } + } + + static Future _handleOptimizedConnection({ + required PlexConnection connection, + required StorageService storage, + required PlexServer server, + required PlexClient? client, + required String reason, + }) async { + final previousUrl = storage.getServerUrl(); + final isNewEndpoint = previousUrl != connection.uri; + + await storage.saveServerUrl(connection.uri); + appLogger.d( + 'Evaluated optimized endpoint candidate', + error: { + 'uri': connection.uri, + 'displayType': connection.displayType, + 'reason': reason, + 'isNewEndpoint': isNewEndpoint, + }, + ); + + if (client != null) { + final prioritizedEndpoints = server.prioritizedEndpointUrls( + preferredFirst: connection.uri, + ); + await client.updateEndpointPreferences( + prioritizedEndpoints, + switchToFirst: isNewEndpoint, + ); + if (isNewEndpoint) { + appLogger.i( + 'Active client switched to optimized endpoint', + error: {'uri': connection.uri, 'reason': reason}, + ); + } + } else if (isNewEndpoint) { + appLogger.i( + 'Stored optimized endpoint for future sessions', + error: {'uri': connection.uri, 'reason': reason}, + ); + } + + if (isNewEndpoint && !connection.uri.startsWith('https://')) { + final upgraded = await server.upgradeConnectionToHttps(connection); + if (upgraded != null && upgraded.uri != connection.uri) { + await _handleOptimizedConnection( + connection: upgraded, + storage: storage, + server: server, + client: client, + reason: '$reason:https-upgrade', + ); + } + } + } } diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index aa156acf..a6e9b89c 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,7 @@ import FlutterMacOS import Foundation +import connectivity_plus import hotkey_manager_macos import macos_window_utils import media_kit_libs_macos_video @@ -21,6 +22,7 @@ import wakelock_plus import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) HotkeyManagerMacosPlugin.register(with: registry.registrar(forPlugin: "HotkeyManagerMacosPlugin")) MacOSWindowUtilsPlugin.register(with: registry.registrar(forPlugin: "MacOSWindowUtilsPlugin")) MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin")) diff --git a/pubspec.lock b/pubspec.lock index 106c8339..5f91f49b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -185,6 +185,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + connectivity_plus: + dependency: "direct main" + description: + name: connectivity_plus + sha256: b5e72753cf63becce2c61fd04dfe0f1c430cc5278b53a1342dc5ad839eab29ec + url: "https://pub.dev" + source: hosted + version: "6.1.5" + connectivity_plus_platform_interface: + dependency: transitive + description: + name: connectivity_plus_platform_interface + sha256: "42657c1715d48b167930d5f34d00222ac100475f73d10162ddf43e714932f204" + url: "https://pub.dev" + source: hosted + version: "2.0.1" convert: dependency: transitive description: @@ -632,6 +648,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + nm: + dependency: transitive + description: + name: nm + sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254" + url: "https://pub.dev" + source: hosted + version: "0.5.0" octo_image: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 5e3b7f22..31042248 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -28,6 +28,7 @@ dependencies: qr_flutter: ^4.1.0 slang: ^3.31.2 slang_flutter: ^3.31.0 + connectivity_plus: ^6.0.5 os_media_controls: git: url: https://github.com/edde746/os-media-controls diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 4e0884f2..e59aa9c2 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,7 @@ #include "generated_plugin_registrant.h" +#include #include #include #include @@ -16,6 +17,8 @@ #include void RegisterPlugins(flutter::PluginRegistry* registry) { + ConnectivityPlusWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); HotkeyManagerWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("HotkeyManagerWindowsPluginCApi")); MediaKitLibsWindowsVideoPluginCApiRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index adfebedf..0c647b45 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + connectivity_plus hotkey_manager_windows media_kit_libs_windows_video media_kit_video From b06609a829b0f1f7d613033abb0c575b7d4b1576 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 15 Nov 2025 01:03:23 +0100 Subject: [PATCH 12/29] fix: android mpv hashes --- pubspec.lock | 32 ++++++++++++++++---------------- pubspec.yaml | 12 ++++++------ 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 5f91f49b..7d033286 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -556,8 +556,8 @@ packages: dependency: "direct main" description: path: media_kit - ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" - resolved-ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" + ref: de5c4e23722999b63ac9fbd648019a2288028721 + resolved-ref: de5c4e23722999b63ac9fbd648019a2288028721 url: "https://github.com/edde746/media-kit" source: git version: "1.2.1" @@ -565,8 +565,8 @@ packages: dependency: "direct overridden" description: path: "libs/android/media_kit_libs_android_video" - ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" - resolved-ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" + ref: de5c4e23722999b63ac9fbd648019a2288028721 + resolved-ref: de5c4e23722999b63ac9fbd648019a2288028721 url: "https://github.com/edde746/media-kit" source: git version: "1.3.9" @@ -574,8 +574,8 @@ packages: dependency: "direct overridden" description: path: "libs/ios/media_kit_libs_ios_video" - ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" - resolved-ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" + ref: de5c4e23722999b63ac9fbd648019a2288028721 + resolved-ref: de5c4e23722999b63ac9fbd648019a2288028721 url: "https://github.com/edde746/media-kit" source: git version: "1.1.4" @@ -583,8 +583,8 @@ packages: dependency: transitive description: path: "libs/linux/media_kit_libs_linux" - ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" - resolved-ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" + ref: de5c4e23722999b63ac9fbd648019a2288028721 + resolved-ref: de5c4e23722999b63ac9fbd648019a2288028721 url: "https://github.com/edde746/media-kit" source: git version: "1.2.1" @@ -592,8 +592,8 @@ packages: dependency: "direct overridden" description: path: "libs/macos/media_kit_libs_macos_video" - ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" - resolved-ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" + ref: de5c4e23722999b63ac9fbd648019a2288028721 + resolved-ref: de5c4e23722999b63ac9fbd648019a2288028721 url: "https://github.com/edde746/media-kit" source: git version: "1.1.5" @@ -601,8 +601,8 @@ packages: dependency: "direct main" description: path: "libs/universal/media_kit_libs_video" - ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" - resolved-ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" + ref: de5c4e23722999b63ac9fbd648019a2288028721 + resolved-ref: de5c4e23722999b63ac9fbd648019a2288028721 url: "https://github.com/edde746/media-kit" source: git version: "1.0.7" @@ -610,8 +610,8 @@ packages: dependency: transitive description: path: "libs/windows/media_kit_libs_windows_video" - ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" - resolved-ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" + ref: de5c4e23722999b63ac9fbd648019a2288028721 + resolved-ref: de5c4e23722999b63ac9fbd648019a2288028721 url: "https://github.com/edde746/media-kit" source: git version: "1.0.12" @@ -619,8 +619,8 @@ packages: dependency: "direct main" description: path: media_kit_video - ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" - resolved-ref: "6bf03092e10e2f6ce19766ed290c0e6610be76f1" + ref: de5c4e23722999b63ac9fbd648019a2288028721 + resolved-ref: de5c4e23722999b63ac9fbd648019a2288028721 url: "https://github.com/edde746/media-kit" source: git version: "1.3.1" diff --git a/pubspec.yaml b/pubspec.yaml index 31042248..1de465c4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,32 +38,32 @@ dependency_overrides: media_kit: git: url: https://github.com/edde746/media-kit - ref: 6bf03092e10e2f6ce19766ed290c0e6610be76f1 + ref: de5c4e23722999b63ac9fbd648019a2288028721 path: media_kit media_kit_video: git: url: https://github.com/edde746/media-kit - ref: 6bf03092e10e2f6ce19766ed290c0e6610be76f1 + ref: de5c4e23722999b63ac9fbd648019a2288028721 path: media_kit_video media_kit_libs_video: git: url: https://github.com/edde746/media-kit - ref: 6bf03092e10e2f6ce19766ed290c0e6610be76f1 + ref: de5c4e23722999b63ac9fbd648019a2288028721 path: libs/universal/media_kit_libs_video media_kit_libs_macos_video: git: url: https://github.com/edde746/media-kit - ref: 6bf03092e10e2f6ce19766ed290c0e6610be76f1 + ref: de5c4e23722999b63ac9fbd648019a2288028721 path: libs/macos/media_kit_libs_macos_video media_kit_libs_ios_video: git: url: https://github.com/edde746/media-kit - ref: 6bf03092e10e2f6ce19766ed290c0e6610be76f1 + ref: de5c4e23722999b63ac9fbd648019a2288028721 path: libs/ios/media_kit_libs_ios_video media_kit_libs_android_video: git: url: https://github.com/edde746/media-kit - ref: 6bf03092e10e2f6ce19766ed290c0e6610be76f1 + ref: de5c4e23722999b63ac9fbd648019a2288028721 path: libs/android/media_kit_libs_android_video dev_dependencies: From a38513db56a573fb33f64d0e3845aeb0162259f0 Mon Sep 17 00:00:00 2001 From: Jovan Date: Sat, 15 Nov 2025 08:08:03 +0800 Subject: [PATCH 13/29] fix code --- lib/i18n/strings_zh.i18n.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/i18n/strings_zh.i18n.json b/lib/i18n/strings_zh.i18n.json index 6fb78497..23ae4ed1 100644 --- a/lib/i18n/strings_zh.i18n.json +++ b/lib/i18n/strings_zh.i18n.json @@ -99,7 +99,7 @@ "largeSkipDuration": "长跳过时长", "secondsUnit": "${seconds} 秒", "defaultSleepTimer": "默认睡眠定时器", - "minutesUnit": "在 ${minutes} 分钟", + "minutesUnit": "${minutes} 分钟", "unwatchedOnly": "仅未观看", "unwatchedOnlyDescription": "随机队列中仅包含未观看的剧集", "shuffleOrderNavigation": "随机顺序导航", @@ -256,7 +256,7 @@ "switchServer": "切换服务器", "logout": "登出", "noContentAvailable": "没有可用内容", - "addMediaToLibraries": "请向的媒体库添加一些媒体", + "addMediaToLibraries": "请向你的媒体库添加一些媒体", "continueWatching": "继续观看", "recentlyAdded": "最近添加", "play": "播放", From efa7f1f724e703f1b5a8b5327b91b458d240479a Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 15 Nov 2025 01:10:26 +0100 Subject: [PATCH 14/29] chore: generate strings --- lib/i18n/strings.g.dart | 1878 +++++++++++++++++++++++++++++- lib/screens/settings_screen.dart | 4 + 2 files changed, 1878 insertions(+), 4 deletions(-) diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 3ce44476..fd030098 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -3,10 +3,10 @@ /// Original: lib/i18n /// To regenerate, run: `dart run slang` /// -/// Locales: 4 -/// Strings: 1264 (316 per locale) +/// Locales: 6 +/// Strings: 1894 (315 per locale) /// -/// Built on 2025-11-14 at 21:16 UTC +/// Built on 2025-11-15 at 00:10 UTC // coverage:ignore-file // ignore_for_file: type=lint @@ -26,9 +26,11 @@ const AppLocale _baseLocale = AppLocale.en; /// - if (LocaleSettings.currentLocale == AppLocale.en) // locale check enum AppLocale with BaseAppLocale { en(languageCode: 'en', build: Translations.build), + de(languageCode: 'de', build: _StringsDe.build), it(languageCode: 'it', build: _StringsIt.build), nl(languageCode: 'nl', build: _StringsNl.build), - sv(languageCode: 'sv', build: _StringsSv.build); + sv(languageCode: 'sv', build: _StringsSv.build), + zh(languageCode: 'zh', build: _StringsZh.build); const AppLocale({required this.languageCode, this.scriptCode, this.countryCode, required this.build}); // ignore: unused_element @@ -738,6 +740,618 @@ class _StringsNavigationEn { String get settings => 'Settings'; } +// Path: +class _StringsDe implements Translations { + /// You can call this constructor and build your own translation instance of this locale. + /// Constructing via the enum [AppLocale.build] is preferred. + _StringsDe.build({Map? overrides, PluralResolver? cardinalResolver, PluralResolver? ordinalResolver}) + : assert(overrides == null, 'Set "translation_overrides: true" in order to enable this feature.'), + $meta = TranslationMetadata( + locale: AppLocale.de, + overrides: overrides ?? {}, + cardinalResolver: cardinalResolver, + ordinalResolver: ordinalResolver, + ) { + $meta.setFlatMapFunction(_flatMapFunction); + } + + /// Metadata for the translations of . + @override final TranslationMetadata $meta; + + /// Access flat map + @override dynamic operator[](String key) => $meta.getTranslation(key); + + @override late final _StringsDe _root = this; // ignore: unused_field + + // Translations + @override late final _StringsAppDe app = _StringsAppDe._(_root); + @override late final _StringsAuthDe auth = _StringsAuthDe._(_root); + @override late final _StringsCommonDe common = _StringsCommonDe._(_root); + @override late final _StringsScreensDe screens = _StringsScreensDe._(_root); + @override late final _StringsUpdateDe update = _StringsUpdateDe._(_root); + @override late final _StringsSettingsDe settings = _StringsSettingsDe._(_root); + @override late final _StringsSearchDe search = _StringsSearchDe._(_root); + @override late final _StringsHotkeysDe hotkeys = _StringsHotkeysDe._(_root); + @override late final _StringsPinEntryDe pinEntry = _StringsPinEntryDe._(_root); + @override late final _StringsFileInfoDe fileInfo = _StringsFileInfoDe._(_root); + @override late final _StringsMediaMenuDe mediaMenu = _StringsMediaMenuDe._(_root); + @override late final _StringsTooltipsDe tooltips = _StringsTooltipsDe._(_root); + @override late final _StringsVideoControlsDe videoControls = _StringsVideoControlsDe._(_root); + @override late final _StringsUserStatusDe userStatus = _StringsUserStatusDe._(_root); + @override late final _StringsMessagesDe messages = _StringsMessagesDe._(_root); + @override late final _StringsProfileDe profile = _StringsProfileDe._(_root); + @override late final _StringsSubtitlingStylingDe subtitlingStyling = _StringsSubtitlingStylingDe._(_root); + @override late final _StringsDialogDe dialog = _StringsDialogDe._(_root); + @override late final _StringsDiscoverDe discover = _StringsDiscoverDe._(_root); + @override late final _StringsErrorsDe errors = _StringsErrorsDe._(_root); + @override late final _StringsLibrariesDe libraries = _StringsLibrariesDe._(_root); + @override late final _StringsAboutDe about = _StringsAboutDe._(_root); + @override late final _StringsServerSelectionDe serverSelection = _StringsServerSelectionDe._(_root); + @override late final _StringsHubDetailDe hubDetail = _StringsHubDetailDe._(_root); + @override late final _StringsLogsDe logs = _StringsLogsDe._(_root); + @override late final _StringsLicensesDe licenses = _StringsLicensesDe._(_root); + @override late final _StringsNavigationDe navigation = _StringsNavigationDe._(_root); +} + +// Path: app +class _StringsAppDe implements _StringsAppEn { + _StringsAppDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get title => 'Plezy'; + @override String get loading => 'Lädt...'; +} + +// Path: auth +class _StringsAuthDe implements _StringsAuthEn { + _StringsAuthDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get signInWithPlex => 'Mit Plex anmelden'; + @override String get showQRCode => 'QR-Code anzeigen'; + @override String get cancel => 'Abbrechen'; + @override String get authenticate => 'Authentifizieren'; + @override String get retry => 'Erneut versuchen'; + @override String get debugEnterToken => 'Debug: Plex-Token eingeben'; + @override String get plexTokenLabel => 'Plex-Auth-Token'; + @override String get plexTokenHint => 'Plex.tv-Token eingeben'; + @override String get authenticationTimeout => 'Authentifizierung abgelaufen. Bitte erneut versuchen.'; + @override String get scanQRCodeInstruction => 'Diesen QR-Code mit einem bei Plex angemeldeten Gerät scannen, um zu authentifizieren.'; + @override String get waitingForAuth => 'Warte auf Authentifizierung...\nBitte Anmeldung im Browser abschließen.'; +} + +// Path: common +class _StringsCommonDe implements _StringsCommonEn { + _StringsCommonDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get cancel => 'Abbrechen'; + @override String get save => 'Speichern'; + @override String get close => 'Schließen'; + @override String get clear => 'Leeren'; + @override String get reset => 'Zurücksetzen'; + @override String get later => 'Später'; + @override String get submit => 'Senden'; + @override String get confirm => 'Bestätigen'; + @override String get retry => 'Erneut versuchen'; + @override String get playNow => 'Jetzt abspielen'; + @override String get logout => 'Abmelden'; + @override String get online => 'Online'; + @override String get offline => 'Offline'; + @override String get owned => 'Eigentum'; + @override String get shared => 'Geteilt'; + @override String get current => 'AKTUELL'; + @override String get unknown => 'Unbekannt'; + @override String get refresh => 'Aktualisieren'; + @override String get yes => 'Ja'; + @override String get no => 'Nein'; + @override String get server => 'Server'; +} + +// Path: screens +class _StringsScreensDe implements _StringsScreensEn { + _StringsScreensDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get licenses => 'Lizenzen'; + @override String get selectServer => 'Server auswählen'; + @override String get switchProfile => 'Profil wechseln'; + @override String get subtitleStyling => 'Untertitel-Stil'; + @override String get search => 'Suche'; + @override String get logs => 'Protokolle'; +} + +// Path: update +class _StringsUpdateDe implements _StringsUpdateEn { + _StringsUpdateDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get available => 'Update verfügbar'; + @override String versionAvailable({required Object version}) => 'Version ${version} ist verfügbar'; + @override String currentVersion({required Object version}) => 'Aktuell: ${version}'; + @override String get skipVersion => 'Diese Version überspringen'; + @override String get viewRelease => 'Release anzeigen'; + @override String get latestVersion => 'Aktuellste Version installiert'; + @override String get checkFailed => 'Fehler bei der Updateprüfung'; +} + +// Path: settings +class _StringsSettingsDe implements _StringsSettingsEn { + _StringsSettingsDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get title => 'Einstellungen'; + @override String get language => 'Sprache'; + @override String get theme => 'Theme'; + @override String get appearance => 'Darstellung'; + @override String get videoPlayback => 'Videowiedergabe'; + @override String get shufflePlay => 'Zufallswiedergabe'; + @override String get advanced => 'Erweitert'; + @override String get useSeasonPostersDescription => 'Staffelposter statt Serienposter für Episoden anzeigen'; + @override String get showHeroSectionDescription => 'Bereich mit empfohlenen Inhalten auf der Startseite anzeigen'; + @override String get secondsLabel => 'Sekunden'; + @override String get minutesLabel => 'Minuten'; + @override String get secondsShort => 's'; + @override String get minutesShort => 'm'; + @override String durationHint({required Object min, required Object max}) => 'Dauer eingeben (${min}-${max})'; + @override String get systemTheme => 'System'; + @override String get systemThemeDescription => 'Systemeinstellungen folgen'; + @override String get lightTheme => 'Hell'; + @override String get darkTheme => 'Dunkel'; + @override String get libraryDensity => 'Bibliotheksdichte'; + @override String get compact => 'Kompakt'; + @override String get compactDescription => 'Kleinere Karten, mehr Elemente sichtbar'; + @override String get normal => 'Normal'; + @override String get normalDescription => 'Standardgröße'; + @override String get comfortable => 'Großzügig'; + @override String get comfortableDescription => 'Größere Karten, weniger Elemente sichtbar'; + @override String get viewMode => 'Ansichtsmodus'; + @override String get gridView => 'Raster'; + @override String get gridViewDescription => 'Elemente im Raster anzeigen'; + @override String get listView => 'Liste'; + @override String get listViewDescription => 'Elemente in Listenansicht anzeigen'; + @override String get useSeasonPosters => 'Staffelposter verwenden'; + @override String get showHeroSection => 'Hero-Bereich anzeigen'; + @override String get hardwareDecoding => 'Hardware-Decodierung'; + @override String get hardwareDecodingDescription => 'Hardwarebeschleunigung verwenden, wenn verfügbar'; + @override String get bufferSize => 'Puffergröße'; + @override String bufferSizeMB({required Object size}) => '${size}MB'; + @override String get subtitleStyling => 'Untertitel-Stil'; + @override String get subtitleStylingDescription => 'Aussehen von Untertiteln anpassen'; + @override String get smallSkipDuration => 'Kleine Sprungdauer'; + @override String get largeSkipDuration => 'Große Sprungdauer'; + @override String secondsUnit({required Object seconds}) => '${seconds} Sekunden'; + @override String get defaultSleepTimer => 'Standard-Sleep-Timer'; + @override String minutesUnit({required Object minutes}) => '${minutes} Minuten'; + @override String get rememberTrackSelections => 'Spurauswahl pro Serie/Film merken'; + @override String get rememberTrackSelectionsDescription => 'Audio- und Untertitelsprache automatisch speichern, wenn während der Wiedergabe geändert'; + @override String get unwatchedOnly => 'Nur ungesehene'; + @override String get unwatchedOnlyDescription => 'Nur ungesehene Episoden in die Shuffle-Warteschlange aufnehmen'; + @override String get shuffleOrderNavigation => 'Navigation der Shuffle-Reihenfolge'; + @override String get shuffleOrderNavigationDescription => 'Weiter/Zurück folgt der zufälligen Reihenfolge'; + @override String get loopShuffleQueue => 'Shuffle-Warteschlange wiederholen'; + @override String get loopShuffleQueueDescription => 'Warteschlange neu starten, wenn das Ende erreicht ist'; + @override String get videoPlayerControls => 'Videoplayer-Steuerung'; + @override String get keyboardShortcuts => 'Tastenkürzel'; + @override String get keyboardShortcutsDescription => 'Tastenkürzel anpassen'; + @override String get debugLogging => 'Debug-Protokollierung'; + @override String get debugLoggingDescription => 'Detaillierte Protokolle zur Fehleranalyse aktivieren'; + @override String get viewLogs => 'Protokolle anzeigen'; + @override String get viewLogsDescription => 'App-Protokolle anzeigen'; + @override String get clearCache => 'Cache löschen'; + @override String get clearCacheDescription => 'Löscht alle zwischengespeicherten Bilder und Daten. Die App kann danach langsamer laden.'; + @override String get clearCacheSuccess => 'Cache erfolgreich gelöscht'; + @override String get resetSettings => 'Einstellungen zurücksetzen'; + @override String get resetSettingsDescription => 'Alle Einstellungen auf Standard zurücksetzen. Dies kann nicht rückgängig gemacht werden.'; + @override String get resetSettingsSuccess => 'Einstellungen erfolgreich zurückgesetzt'; + @override String get shortcutsReset => 'Tastenkürzel auf Standard zurückgesetzt'; + @override String get about => 'Über'; + @override String get aboutDescription => 'App-Informationen und Lizenzen'; + @override String get updates => 'Updates'; + @override String get updateAvailable => 'Update verfügbar'; + @override String get checkForUpdates => 'Nach Updates suchen'; + @override String get validationErrorEnterNumber => 'Bitte eine gültige Zahl eingeben'; + @override String validationErrorDuration({required Object min, required Object max, required Object unit}) => 'Dauer muss zwischen ${min} und ${max} ${unit} liegen'; + @override String shortcutAlreadyAssigned({required Object action}) => 'Tastenkürzel bereits zugewiesen an ${action}'; + @override String shortcutUpdated({required Object action}) => 'Tastenkürzel aktualisiert für ${action}'; +} + +// Path: search +class _StringsSearchDe implements _StringsSearchEn { + _StringsSearchDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get hint => 'Filme, Serien, Musik suchen...'; + @override String get tryDifferentTerm => 'Anderen Suchbegriff versuchen'; + @override String get searchYourMedia => 'In den eigenen Medien suchen'; + @override String get enterTitleActorOrKeyword => 'Titel, Schauspieler oder Stichwort eingeben'; +} + +// Path: hotkeys +class _StringsHotkeysDe implements _StringsHotkeysEn { + _StringsHotkeysDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String setShortcutFor({required Object actionName}) => 'Tastenkürzel festlegen für ${actionName}'; + @override String get clearShortcut => 'Kürzel löschen'; +} + +// Path: pinEntry +class _StringsPinEntryDe implements _StringsPinEntryEn { + _StringsPinEntryDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get enterPin => 'PIN eingeben'; + @override String get showPin => 'PIN anzeigen'; + @override String get hidePin => 'PIN verbergen'; +} + +// Path: fileInfo +class _StringsFileInfoDe implements _StringsFileInfoEn { + _StringsFileInfoDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get title => 'Dateiinfo'; + @override String get video => 'Video'; + @override String get audio => 'Audio'; + @override String get file => 'Datei'; + @override String get advanced => 'Erweitert'; + @override String get codec => 'Codec'; + @override String get resolution => 'Auflösung'; + @override String get bitrate => 'Bitrate'; + @override String get frameRate => 'Bildrate'; + @override String get aspectRatio => 'Seitenverhältnis'; + @override String get profile => 'Profil'; + @override String get bitDepth => 'Farbtiefe'; + @override String get colorSpace => 'Farbraum'; + @override String get colorRange => 'Farbbereich'; + @override String get colorPrimaries => 'Primärfarben'; + @override String get chromaSubsampling => 'Chroma-Subsampling'; + @override String get channels => 'Kanäle'; + @override String get path => 'Pfad'; + @override String get size => 'Größe'; + @override String get container => 'Container'; + @override String get duration => 'Dauer'; + @override String get optimizedForStreaming => 'Für Streaming optimiert'; + @override String get has64bitOffsets => '64-Bit-Offsets'; +} + +// Path: mediaMenu +class _StringsMediaMenuDe implements _StringsMediaMenuEn { + _StringsMediaMenuDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get markAsWatched => 'Als gesehen markieren'; + @override String get markAsUnwatched => 'Als ungesehen markieren'; + @override String get removeFromContinueWatching => 'Aus ‚Weiterschauen‘ entfernen'; + @override String get goToSeries => 'Zur Serie'; + @override String get goToSeason => 'Zur Staffel'; + @override String get shufflePlay => 'Zufallswiedergabe'; + @override String get fileInfo => 'Dateiinfo'; +} + +// Path: tooltips +class _StringsTooltipsDe implements _StringsTooltipsEn { + _StringsTooltipsDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get shufflePlay => 'Zufallswiedergabe'; + @override String get markAsWatched => 'Als gesehen markieren'; + @override String get markAsUnwatched => 'Als ungesehen markieren'; +} + +// Path: videoControls +class _StringsVideoControlsDe implements _StringsVideoControlsEn { + _StringsVideoControlsDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get audioLabel => 'Audio'; + @override String get subtitlesLabel => 'Untertitel'; + @override String get resetToZero => 'Auf 0 ms zurücksetzen'; + @override String addTime({required Object amount, required Object unit}) => '+${amount}${unit}'; + @override String minusTime({required Object amount, required Object unit}) => '-${amount}${unit}'; + @override String playsLater({required Object label}) => '${label} spielt später'; + @override String playsEarlier({required Object label}) => '${label} spielt früher'; + @override String get noOffset => 'Kein Offset'; + @override String get letterbox => 'Letterbox'; + @override String get fillScreen => 'Bild füllen'; + @override String get stretch => 'Strecken'; + @override String get lockRotation => 'Rotation sperren'; + @override String get unlockRotation => 'Rotation entsperren'; +} + +// Path: userStatus +class _StringsUserStatusDe implements _StringsUserStatusEn { + _StringsUserStatusDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get admin => 'Admin'; + @override String get restricted => 'Eingeschränkt'; + @override String get protected => 'Geschützt'; +} + +// Path: messages +class _StringsMessagesDe implements _StringsMessagesEn { + _StringsMessagesDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get markedAsWatched => 'Als gesehen markiert'; + @override String get markedAsUnwatched => 'Als ungesehen markiert'; + @override String get removedFromContinueWatching => 'Aus ‚Weiterschauen‘ entfernt'; + @override String errorLoading({required Object error}) => 'Fehler: ${error}'; + @override String get fileInfoNotAvailable => 'Dateiinfo nicht verfügbar'; + @override String errorLoadingFileInfo({required Object error}) => 'Fehler beim Laden der Dateiinfo: ${error}'; + @override String get errorLoadingSeries => 'Fehler beim Laden der Serie'; + @override String get errorLoadingSeason => 'Fehler beim Laden der Staffel'; + @override String get musicNotSupported => 'Musikwiedergabe wird noch nicht unterstützt'; + @override String get logsCleared => 'Protokolle gelöscht'; + @override String get logsCopied => 'Protokolle in Zwischenablage kopiert'; + @override String get noLogsAvailable => 'Keine Protokolle verfügbar'; + @override String libraryScanning({required Object title}) => 'Scanne „${title}“...'; + @override String libraryScanStarted({required Object title}) => 'Bibliotheksscan gestartet für „${title}“'; + @override String libraryScanFailed({required Object error}) => 'Fehler beim Scannen der Bibliothek: ${error}'; + @override String metadataRefreshing({required Object title}) => 'Metadaten werden aktualisiert für „${title}“...'; + @override String metadataRefreshStarted({required Object title}) => 'Metadaten-Aktualisierung gestartet für „${title}“'; + @override String metadataRefreshFailed({required Object error}) => 'Metadaten konnten nicht aktualisiert werden: ${error}'; + @override String get noPlexToken => 'Kein Plex-Token gefunden. Bitte erneut anmelden.'; + @override String get logoutConfirm => 'Abmeldung wirklich durchführen?'; + @override String get noSeasonsFound => 'Keine Staffeln gefunden'; + @override String get noEpisodesFound => 'Keine Episoden in der ersten Staffel gefunden'; + @override String get noEpisodesFoundGeneral => 'Keine Episoden gefunden'; + @override String get noResultsFound => 'Keine Ergebnisse gefunden'; + @override String sleepTimerSet({required Object label}) => 'Sleep-Timer gesetzt auf ${label}'; + @override String failedToSwitchProfile({required Object displayName}) => 'Profilwechsel zu ${displayName} fehlgeschlagen'; +} + +// Path: profile +class _StringsProfileDe implements _StringsProfileEn { + _StringsProfileDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get noUsersAvailable => 'Keine Benutzer verfügbar'; +} + +// Path: subtitlingStyling +class _StringsSubtitlingStylingDe implements _StringsSubtitlingStylingEn { + _StringsSubtitlingStylingDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get stylingOptions => 'Stiloptionen'; + @override String get fontSize => 'Schriftgröße'; + @override String get textColor => 'Textfarbe'; + @override String get borderSize => 'Rahmengröße'; + @override String get borderColor => 'Rahmenfarbe'; + @override String get backgroundOpacity => 'Hintergrunddeckkraft'; + @override String get backgroundColor => 'Hintergrundfarbe'; +} + +// Path: dialog +class _StringsDialogDe implements _StringsDialogEn { + _StringsDialogDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get confirmAction => 'Aktion bestätigen'; + @override String get areYouSure => 'Aktion wirklich ausführen?'; + @override String get cancel => 'Abbrechen'; + @override String get playNow => 'Jetzt abspielen'; +} + +// Path: discover +class _StringsDiscoverDe implements _StringsDiscoverEn { + _StringsDiscoverDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get title => 'Entdecken'; + @override String get switchProfile => 'Profil wechseln'; + @override String get switchServer => 'Server wechseln'; + @override String get logout => 'Abmelden'; + @override String get noContentAvailable => 'Kein Inhalt verfügbar'; + @override String get addMediaToLibraries => 'Medien zur Bibliothek hinzufügen'; + @override String get continueWatching => 'Weiterschauen'; + @override String get recentlyAdded => 'Kürzlich hinzugefügt'; + @override String get play => 'Abspielen'; + @override String get resume => 'Fortsetzen'; + @override String playEpisode({required Object season, required Object episode}) => 'Spiele S${season}, E${episode}'; + @override String resumeEpisode({required Object season, required Object episode}) => 'Fortsetzen S${season}, E${episode}'; + @override String get pause => 'Pause'; + @override String get overview => 'Übersicht'; + @override String get cast => 'Besetzung'; + @override String episodeCount({required Object count}) => '${count} Episoden'; + @override String watchedProgress({required Object watched, required Object total}) => '${watched}/${total} gesehen'; + @override String get movie => 'Film'; + @override String get tvShow => 'Serie'; + @override String minutesLeft({required Object minutes}) => '${minutes} Min übrig'; +} + +// Path: errors +class _StringsErrorsDe implements _StringsErrorsEn { + _StringsErrorsDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String searchFailed({required Object error}) => 'Suche fehlgeschlagen: ${error}'; + @override String connectionTimeout({required Object context}) => 'Zeitüberschreitung beim Laden von ${context}'; + @override String get connectionFailed => 'Verbindung zum Plex-Server fehlgeschlagen'; + @override String failedToLoad({required Object context, required Object error}) => 'Fehler beim Laden von ${context}: ${error}'; + @override String get noClientAvailable => 'Kein Client verfügbar'; + @override String authenticationFailed({required Object error}) => 'Authentifizierung fehlgeschlagen: ${error}'; + @override String get couldNotLaunchUrl => 'Auth-URL konnte nicht geöffnet werden'; + @override String get pleaseEnterToken => 'Bitte Token eingeben'; + @override String get invalidToken => 'Ungültiges Token'; + @override String failedToVerifyToken({required Object error}) => 'Token-Verifizierung fehlgeschlagen: ${error}'; + @override String failedToSwitchProfile({required Object displayName}) => 'Profilwechsel zu ${displayName} fehlgeschlagen'; + @override String get connectionFailedGeneric => 'Verbindung fehlgeschlagen'; +} + +// Path: libraries +class _StringsLibrariesDe implements _StringsLibrariesEn { + _StringsLibrariesDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get title => 'Bibliotheken'; + @override String get scanLibraryFiles => 'Bibliotheksdateien scannen'; + @override String get scanLibrary => 'Bibliothek scannen'; + @override String get analyze => 'Analysieren'; + @override String get analyzeLibrary => 'Bibliothek analysieren'; + @override String get refreshMetadata => 'Metadaten aktualisieren'; + @override String get emptyTrash => 'Papierkorb leeren'; + @override String emptyingTrash({required Object title}) => 'Papierkorb für „${title}“ wird geleert...'; + @override String trashEmptied({required Object title}) => 'Papierkorb für „${title}“ geleert'; + @override String failedToEmptyTrash({required Object error}) => 'Papierkorb konnte nicht geleert werden: ${error}'; + @override String analyzing({required Object title}) => 'Analysiere „${title}“...'; + @override String analysisStarted({required Object title}) => 'Analyse gestartet für „${title}“'; + @override String failedToAnalyze({required Object error}) => 'Analyse der Bibliothek fehlgeschlagen: ${error}'; + @override String get noLibrariesFound => 'Keine Bibliotheken gefunden'; + @override String get thisLibraryIsEmpty => 'Diese Bibliothek ist leer'; + @override String get all => 'Alle'; + @override String get clearAll => 'Alle löschen'; + @override String scanLibraryConfirm({required Object title}) => '„${title}“ wirklich scannen?'; + @override String analyzeLibraryConfirm({required Object title}) => '„${title}“ wirklich analysieren?'; + @override String refreshMetadataConfirm({required Object title}) => 'Metadaten für „${title}“ wirklich aktualisieren?'; + @override String emptyTrashConfirm({required Object title}) => 'Papierkorb für „${title}“ wirklich leeren?'; + @override String get manageLibraries => 'Bibliotheken verwalten'; + @override String get sort => 'Sortieren'; + @override String get sortBy => 'Sortieren nach'; + @override String get filters => 'Filter'; + @override String loadingLibraryWithCount({required Object count}) => 'Lade Bibliothek... (${count} Elemente geladen)'; + @override String get confirmActionMessage => 'Aktion wirklich durchführen?'; + @override String get showLibrary => 'Bibliothek anzeigen'; + @override String get hideLibrary => 'Bibliothek ausblenden'; + @override String get libraryOptions => 'Bibliotheksoptionen'; +} + +// Path: about +class _StringsAboutDe implements _StringsAboutEn { + _StringsAboutDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get title => 'Über'; + @override String get openSourceLicenses => 'Open-Source-Lizenzen'; + @override String versionLabel({required Object version}) => 'Version ${version}'; + @override String get appDescription => 'Ein schöner Plex-Client für Flutter'; + @override String get viewLicensesDescription => 'Lizenzen von Drittanbieter-Bibliotheken anzeigen'; +} + +// Path: serverSelection +class _StringsServerSelectionDe implements _StringsServerSelectionEn { + _StringsServerSelectionDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get connectingToServer => 'Verbinde mit Server...'; + @override String get serverDebugCopied => 'Server-Debugdaten kopiert'; + @override String get copyDebugData => 'Debugdaten kopieren'; + @override String get noServersFound => 'Keine Server gefunden'; + @override String malformedServerData({required Object count}) => '${count} Server mit fehlerhaften Daten gefunden. Keine gültigen Server verfügbar.'; + @override String get incompleteServerInfo => 'Einige Serverinformationen sind unvollständig und wurden übersprungen. Plex.tv-Konto prüfen.'; + @override String get incompleteConnectionInfo => 'Serververbindungsinformationen unvollständig. Bitte erneut versuchen.'; + @override String malformedServerInfo({required Object message}) => 'Serverinformationen fehlerhaft: ${message}'; + @override String get networkConnectionFailed => 'Netzwerkverbindung fehlgeschlagen. Internetverbindung prüfen und erneut versuchen.'; + @override String get authenticationFailed => 'Authentifizierung fehlgeschlagen. Bitte erneut anmelden.'; + @override String get plexServiceUnavailable => 'Plex-Dienst nicht verfügbar. Bitte später erneut versuchen.'; + @override String failedToLoadServers({required Object error}) => 'Server konnten nicht geladen werden: ${error}'; +} + +// Path: hubDetail +class _StringsHubDetailDe implements _StringsHubDetailEn { + _StringsHubDetailDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get title => 'Titel'; + @override String get releaseYear => 'Erscheinungsjahr'; + @override String get dateAdded => 'Hinzugefügt am'; + @override String get rating => 'Bewertung'; + @override String get noItemsFound => 'Keine Elemente gefunden'; +} + +// Path: logs +class _StringsLogsDe implements _StringsLogsEn { + _StringsLogsDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get title => 'Protokolle'; + @override String get clearLogs => 'Protokolle löschen'; + @override String get copyLogs => 'Protokolle kopieren'; + @override String get exportLogs => 'Protokolle exportieren'; + @override String get noLogsToShow => 'Keine Protokolle zum Anzeigen'; + @override String get error => 'Fehler:'; + @override String get stackTrace => 'Stacktrace:'; +} + +// Path: licenses +class _StringsLicensesDe implements _StringsLicensesEn { + _StringsLicensesDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get relatedPackages => 'Verwandte Pakete'; + @override String get license => 'Lizenz'; + @override String licenseNumber({required Object number}) => 'Lizenz ${number}'; + @override String licensesCount({required Object count}) => '${count} Lizenzen'; +} + +// Path: navigation +class _StringsNavigationDe implements _StringsNavigationEn { + _StringsNavigationDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get home => 'Start'; + @override String get search => 'Suche'; + @override String get libraries => 'Bibliotheken'; + @override String get settings => 'Einstellungen'; +} + // Path: class _StringsIt implements Translations { /// You can call this constructor and build your own translation instance of this locale. @@ -2574,6 +3188,616 @@ class _StringsNavigationSv implements _StringsNavigationEn { @override String get settings => 'Inställningar'; } +// Path: +class _StringsZh implements Translations { + /// You can call this constructor and build your own translation instance of this locale. + /// Constructing via the enum [AppLocale.build] is preferred. + _StringsZh.build({Map? overrides, PluralResolver? cardinalResolver, PluralResolver? ordinalResolver}) + : assert(overrides == null, 'Set "translation_overrides: true" in order to enable this feature.'), + $meta = TranslationMetadata( + locale: AppLocale.zh, + overrides: overrides ?? {}, + cardinalResolver: cardinalResolver, + ordinalResolver: ordinalResolver, + ) { + $meta.setFlatMapFunction(_flatMapFunction); + } + + /// Metadata for the translations of . + @override final TranslationMetadata $meta; + + /// Access flat map + @override dynamic operator[](String key) => $meta.getTranslation(key); + + @override late final _StringsZh _root = this; // ignore: unused_field + + // Translations + @override late final _StringsAppZh app = _StringsAppZh._(_root); + @override late final _StringsAuthZh auth = _StringsAuthZh._(_root); + @override late final _StringsCommonZh common = _StringsCommonZh._(_root); + @override late final _StringsScreensZh screens = _StringsScreensZh._(_root); + @override late final _StringsUpdateZh update = _StringsUpdateZh._(_root); + @override late final _StringsSettingsZh settings = _StringsSettingsZh._(_root); + @override late final _StringsSearchZh search = _StringsSearchZh._(_root); + @override late final _StringsHotkeysZh hotkeys = _StringsHotkeysZh._(_root); + @override late final _StringsPinEntryZh pinEntry = _StringsPinEntryZh._(_root); + @override late final _StringsFileInfoZh fileInfo = _StringsFileInfoZh._(_root); + @override late final _StringsMediaMenuZh mediaMenu = _StringsMediaMenuZh._(_root); + @override late final _StringsTooltipsZh tooltips = _StringsTooltipsZh._(_root); + @override late final _StringsVideoControlsZh videoControls = _StringsVideoControlsZh._(_root); + @override late final _StringsUserStatusZh userStatus = _StringsUserStatusZh._(_root); + @override late final _StringsMessagesZh messages = _StringsMessagesZh._(_root); + @override late final _StringsProfileZh profile = _StringsProfileZh._(_root); + @override late final _StringsSubtitlingStylingZh subtitlingStyling = _StringsSubtitlingStylingZh._(_root); + @override late final _StringsDialogZh dialog = _StringsDialogZh._(_root); + @override late final _StringsDiscoverZh discover = _StringsDiscoverZh._(_root); + @override late final _StringsErrorsZh errors = _StringsErrorsZh._(_root); + @override late final _StringsLibrariesZh libraries = _StringsLibrariesZh._(_root); + @override late final _StringsAboutZh about = _StringsAboutZh._(_root); + @override late final _StringsServerSelectionZh serverSelection = _StringsServerSelectionZh._(_root); + @override late final _StringsHubDetailZh hubDetail = _StringsHubDetailZh._(_root); + @override late final _StringsLogsZh logs = _StringsLogsZh._(_root); + @override late final _StringsLicensesZh licenses = _StringsLicensesZh._(_root); + @override late final _StringsNavigationZh navigation = _StringsNavigationZh._(_root); +} + +// Path: app +class _StringsAppZh implements _StringsAppEn { + _StringsAppZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get title => 'Plezy'; + @override String get loading => '加载中...'; +} + +// Path: auth +class _StringsAuthZh implements _StringsAuthEn { + _StringsAuthZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get signInWithPlex => '使用 Plex 登录'; + @override String get showQRCode => '显示二维码'; + @override String get cancel => '取消'; + @override String get authenticate => '验证'; + @override String get retry => '重试'; + @override String get debugEnterToken => '调试:输入 Plex Token'; + @override String get plexTokenLabel => 'Plex 授权令牌 (Auth Token)'; + @override String get plexTokenHint => '输入你的 Plex.tv 令牌'; + @override String get authenticationTimeout => '验证超时。请重试。'; + @override String get scanQRCodeInstruction => '请使用已登录 Plex 的设备扫描此二维码进行验证。'; + @override String get waitingForAuth => '等待验证中...\n请在你的浏览器中完成登录。'; +} + +// Path: common +class _StringsCommonZh implements _StringsCommonEn { + _StringsCommonZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get cancel => '取消'; + @override String get save => '保存'; + @override String get close => '关闭'; + @override String get clear => '清除'; + @override String get reset => '重置'; + @override String get later => '稍后'; + @override String get submit => '提交'; + @override String get confirm => '确认'; + @override String get retry => '重试'; + @override String get playNow => '立即播放'; + @override String get logout => '登出'; + @override String get online => '在线'; + @override String get offline => '离线'; + @override String get owned => '拥有'; + @override String get shared => '共享'; + @override String get current => '当前'; + @override String get unknown => '未知'; + @override String get refresh => '刷新'; + @override String get yes => '是'; + @override String get no => '否'; + @override String get server => '服务器'; +} + +// Path: screens +class _StringsScreensZh implements _StringsScreensEn { + _StringsScreensZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get licenses => '许可证'; + @override String get selectServer => '选择服务器'; + @override String get switchProfile => '切换配置文件'; + @override String get subtitleStyling => '字幕样式'; + @override String get search => '搜索'; + @override String get logs => '日志'; +} + +// Path: update +class _StringsUpdateZh implements _StringsUpdateEn { + _StringsUpdateZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get available => '有可用更新'; + @override String versionAvailable({required Object version}) => '版本 ${version} 已发布'; + @override String currentVersion({required Object version}) => '当前版本: ${version}'; + @override String get skipVersion => '跳过此版本'; + @override String get viewRelease => '查看发布详情'; + @override String get latestVersion => '已安装的版本是可用的最新版本'; + @override String get checkFailed => '无法检查更新'; +} + +// Path: settings +class _StringsSettingsZh implements _StringsSettingsEn { + _StringsSettingsZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get title => '设置'; + @override String get language => '语言'; + @override String get theme => '主题'; + @override String get appearance => '外观'; + @override String get videoPlayback => '视频播放'; + @override String get shufflePlay => '随机播放'; + @override String get advanced => '高级'; + @override String get useSeasonPostersDescription => '为剧集显示季海报而非剧集海报'; + @override String get showHeroSectionDescription => '在主屏幕上显示精选内容轮播区'; + @override String get secondsLabel => '秒'; + @override String get minutesLabel => '分钟'; + @override String get secondsShort => 's'; + @override String get minutesShort => 'm'; + @override String durationHint({required Object min, required Object max}) => '输入时长 (${min}-${max})'; + @override String get systemTheme => '系统'; + @override String get systemThemeDescription => '跟随系统设置'; + @override String get lightTheme => '浅色'; + @override String get darkTheme => '深色'; + @override String get libraryDensity => '媒体库密度'; + @override String get compact => '紧凑'; + @override String get compactDescription => '卡片更小,显示更多项目'; + @override String get normal => '标准'; + @override String get normalDescription => '默认尺寸'; + @override String get comfortable => '舒适'; + @override String get comfortableDescription => '卡片更大,显示更少项目'; + @override String get viewMode => '视图模式'; + @override String get gridView => '网格视图'; + @override String get gridViewDescription => '以网格布局显示项目'; + @override String get listView => '列表视图'; + @override String get listViewDescription => '以列表布局显示项目'; + @override String get useSeasonPosters => '使用季海报'; + @override String get showHeroSection => '显示主要精选区'; + @override String get hardwareDecoding => '硬件解码'; + @override String get hardwareDecodingDescription => '如果可用,使用硬件加速'; + @override String get bufferSize => '缓冲区大小'; + @override String bufferSizeMB({required Object size}) => '${size}MB'; + @override String get subtitleStyling => '字幕样式'; + @override String get subtitleStylingDescription => '调整字幕外观'; + @override String get smallSkipDuration => '短跳过时长'; + @override String get largeSkipDuration => '长跳过时长'; + @override String secondsUnit({required Object seconds}) => '${seconds} 秒'; + @override String get defaultSleepTimer => '默认睡眠定时器'; + @override String minutesUnit({required Object minutes}) => '${minutes} 分钟'; + @override String get unwatchedOnly => '仅未观看'; + @override String get unwatchedOnlyDescription => '随机队列中仅包含未观看的剧集'; + @override String get shuffleOrderNavigation => '随机顺序导航'; + @override String get shuffleOrderNavigationDescription => '下一集/上一集按钮遵循随机播放顺序'; + @override String get loopShuffleQueue => '循环随机队列'; + @override String get loopShuffleQueueDescription => '在队列结束时重新开始'; + @override String get videoPlayerControls => '视频播放器控制'; + @override String get keyboardShortcuts => '键盘快捷键'; + @override String get keyboardShortcutsDescription => '自定义键盘快捷键'; + @override String get debugLogging => '调试日志'; + @override String get debugLoggingDescription => '启用详细日志记录以便故障排除'; + @override String get viewLogs => '查看日志'; + @override String get viewLogsDescription => '查看应用程序日志'; + @override String get clearCache => '清除缓存'; + @override String get clearCacheDescription => '这将清除所有缓存的图片和数据。清除缓存后,应用程序加载内容可能会变慢。'; + @override String get clearCacheSuccess => '缓存清除成功'; + @override String get resetSettings => '重置设置'; + @override String get resetSettingsDescription => '这会将所有设置重置为其默认值。此操作无法撤销。'; + @override String get resetSettingsSuccess => '设置重置成功'; + @override String get shortcutsReset => '快捷键已重置为默认值'; + @override String get about => '关于'; + @override String get aboutDescription => '应用程序信息和许可证'; + @override String get updates => '更新'; + @override String get updateAvailable => '有可用更新'; + @override String get checkForUpdates => '检查更新'; + @override String get validationErrorEnterNumber => '请输入一个有效的数字'; + @override String validationErrorDuration({required Object min, required Object max, required Object unit}) => '时长必须介于 ${min} 和 ${max} ${unit} 之间'; + @override String shortcutAlreadyAssigned({required Object action}) => '快捷键已被分配给 ${action}'; + @override String shortcutUpdated({required Object action}) => '快捷键已为 ${action} 更新'; +} + +// Path: search +class _StringsSearchZh implements _StringsSearchEn { + _StringsSearchZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get hint => '搜索电影、系列、音乐...'; + @override String get tryDifferentTerm => '尝试不同的搜索词'; + @override String get searchYourMedia => '搜索媒体'; + @override String get enterTitleActorOrKeyword => '输入标题、演员或关键词'; +} + +// Path: hotkeys +class _StringsHotkeysZh implements _StringsHotkeysEn { + _StringsHotkeysZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String setShortcutFor({required Object actionName}) => '为 ${actionName} 设置快捷键'; + @override String get clearShortcut => '清除快捷键'; +} + +// Path: pinEntry +class _StringsPinEntryZh implements _StringsPinEntryEn { + _StringsPinEntryZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get enterPin => '输入 PIN'; + @override String get showPin => '显示 PIN'; + @override String get hidePin => '隐藏 PIN'; +} + +// Path: fileInfo +class _StringsFileInfoZh implements _StringsFileInfoEn { + _StringsFileInfoZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get title => '文件信息'; + @override String get video => '视频'; + @override String get audio => '音频'; + @override String get file => '文件'; + @override String get advanced => '高级'; + @override String get codec => '编解码器'; + @override String get resolution => '分辨率'; + @override String get bitrate => '比特率'; + @override String get frameRate => '帧率'; + @override String get aspectRatio => '宽高比'; + @override String get profile => '配置文件'; + @override String get bitDepth => '位深度'; + @override String get colorSpace => '色彩空间'; + @override String get colorRange => '色彩范围'; + @override String get colorPrimaries => '颜色原色'; + @override String get chromaSubsampling => '色度子采样'; + @override String get channels => '声道'; + @override String get path => '路径'; + @override String get size => '大小'; + @override String get container => '容器'; + @override String get duration => '时长'; + @override String get optimizedForStreaming => '已优化用于流媒体'; + @override String get has64bitOffsets => '64位偏移量'; +} + +// Path: mediaMenu +class _StringsMediaMenuZh implements _StringsMediaMenuEn { + _StringsMediaMenuZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get markAsWatched => '标记为已观看'; + @override String get markAsUnwatched => '标记为未观看'; + @override String get removeFromContinueWatching => '从继续观看中移除'; + @override String get goToSeries => '转到系列'; + @override String get goToSeason => '转到季'; + @override String get shufflePlay => '随机播放'; + @override String get fileInfo => '文件信息'; +} + +// Path: tooltips +class _StringsTooltipsZh implements _StringsTooltipsEn { + _StringsTooltipsZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get shufflePlay => '随机播放'; + @override String get markAsWatched => '标记为已观看'; + @override String get markAsUnwatched => '标记为未观看'; +} + +// Path: videoControls +class _StringsVideoControlsZh implements _StringsVideoControlsEn { + _StringsVideoControlsZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get audioLabel => '音频'; + @override String get subtitlesLabel => '字幕'; + @override String get resetToZero => '重置为 0ms'; + @override String addTime({required Object amount, required Object unit}) => '+${amount}${unit}'; + @override String minusTime({required Object amount, required Object unit}) => '-${amount}${unit}'; + @override String playsLater({required Object label}) => '${label} 播放较晚'; + @override String playsEarlier({required Object label}) => '${label} 播放较早'; + @override String get noOffset => '无偏移'; + @override String get letterbox => '信箱模式(Letterbox)'; + @override String get fillScreen => '填充屏幕'; + @override String get stretch => '拉伸'; + @override String get lockRotation => '锁定旋转'; + @override String get unlockRotation => '解锁旋转'; +} + +// Path: userStatus +class _StringsUserStatusZh implements _StringsUserStatusEn { + _StringsUserStatusZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get admin => '管理员'; + @override String get restricted => '受限'; + @override String get protected => '受保护'; +} + +// Path: messages +class _StringsMessagesZh implements _StringsMessagesEn { + _StringsMessagesZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get markedAsWatched => '已标记为已观看'; + @override String get markedAsUnwatched => '已标记为未观看'; + @override String get removedFromContinueWatching => '已从继续观看中移除'; + @override String errorLoading({required Object error}) => '错误: ${error}'; + @override String get fileInfoNotAvailable => '文件信息不可用'; + @override String errorLoadingFileInfo({required Object error}) => '加载文件信息时出错: ${error}'; + @override String get errorLoadingSeries => '加载系列时出错'; + @override String get errorLoadingSeason => '加载季时出错'; + @override String get musicNotSupported => '尚不支持播放音乐'; + @override String get logsCleared => '日志已清除'; + @override String get logsCopied => '日志已复制到剪贴板'; + @override String get noLogsAvailable => '没有可用日志'; + @override String libraryScanning({required Object title}) => '正在扫描 “${title}”...'; + @override String libraryScanStarted({required Object title}) => '已开始扫描 “${title}” 媒体库'; + @override String libraryScanFailed({required Object error}) => '无法扫描媒体库: ${error}'; + @override String metadataRefreshing({required Object title}) => '正在刷新 “${title}” 的元数据...'; + @override String metadataRefreshStarted({required Object title}) => '已开始刷新 “${title}” 的元数据'; + @override String metadataRefreshFailed({required Object error}) => '无法刷新元数据: ${error}'; + @override String get noPlexToken => '未找到 Plex 令牌。请重新登录。'; + @override String get logoutConfirm => '你确定要登出吗?'; + @override String get noSeasonsFound => '未找到季'; + @override String get noEpisodesFound => '在第一季中未找到剧集'; + @override String get noEpisodesFoundGeneral => '未找到剧集'; + @override String get noResultsFound => '未找到结果'; + @override String sleepTimerSet({required Object label}) => '睡眠定时器已设置为 ${label}'; + @override String failedToSwitchProfile({required Object displayName}) => '无法切换到 ${displayName}'; +} + +// Path: profile +class _StringsProfileZh implements _StringsProfileEn { + _StringsProfileZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get noUsersAvailable => '没有可用用户'; +} + +// Path: subtitlingStyling +class _StringsSubtitlingStylingZh implements _StringsSubtitlingStylingEn { + _StringsSubtitlingStylingZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get stylingOptions => '样式选项'; + @override String get fontSize => '字号'; + @override String get textColor => '文本颜色'; + @override String get borderSize => '边框大小'; + @override String get borderColor => '边框颜色'; + @override String get backgroundOpacity => '背景不透明度'; + @override String get backgroundColor => '背景颜色'; +} + +// Path: dialog +class _StringsDialogZh implements _StringsDialogEn { + _StringsDialogZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get confirmAction => '确认操作'; + @override String get areYouSure => '确定要执行此操作吗?'; + @override String get cancel => '取消'; + @override String get playNow => '立即播放'; +} + +// Path: discover +class _StringsDiscoverZh implements _StringsDiscoverEn { + _StringsDiscoverZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get title => '发现'; + @override String get switchProfile => '切换配置文件'; + @override String get switchServer => '切换服务器'; + @override String get logout => '登出'; + @override String get noContentAvailable => '没有可用内容'; + @override String get addMediaToLibraries => '请向你的媒体库添加一些媒体'; + @override String get continueWatching => '继续观看'; + @override String get recentlyAdded => '最近添加'; + @override String get play => '播放'; + @override String get resume => '继续'; + @override String playEpisode({required Object season, required Object episode}) => '播放 S${season}, E${episode}'; + @override String resumeEpisode({required Object season, required Object episode}) => '继续 S${season}, E${episode}'; + @override String get pause => '暂停'; + @override String get overview => '概述'; + @override String get cast => '演员表'; + @override String episodeCount({required Object count}) => '${count} 集'; + @override String watchedProgress({required Object watched, required Object total}) => '已观看 ${watched}/${total} 集'; + @override String get movie => '电影'; + @override String get tvShow => '电视剧'; + @override String minutesLeft({required Object minutes}) => '剩余 ${minutes} 分钟'; +} + +// Path: errors +class _StringsErrorsZh implements _StringsErrorsEn { + _StringsErrorsZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String searchFailed({required Object error}) => '搜索失败: ${error}'; + @override String connectionTimeout({required Object context}) => '加载 ${context} 时连接超时'; + @override String get connectionFailed => '无法连接到 Plex 服务器'; + @override String failedToLoad({required Object context, required Object error}) => '无法加载 ${context}: ${error}'; + @override String get noClientAvailable => '没有可用客户端'; + @override String authenticationFailed({required Object error}) => '验证失败: ${error}'; + @override String get couldNotLaunchUrl => '无法打开授权 URL'; + @override String get pleaseEnterToken => '请输入一个令牌'; + @override String get invalidToken => '令牌无效'; + @override String failedToVerifyToken({required Object error}) => '无法验证令牌: ${error}'; + @override String failedToSwitchProfile({required Object displayName}) => '无法切换到 ${displayName}'; + @override String get connectionFailedGeneric => '连接失败'; +} + +// Path: libraries +class _StringsLibrariesZh implements _StringsLibrariesEn { + _StringsLibrariesZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get title => '媒体库'; + @override String get scanLibraryFiles => '扫描媒体库文件'; + @override String get scanLibrary => '扫描媒体库'; + @override String get analyze => '分析'; + @override String get analyzeLibrary => '分析媒体库'; + @override String get refreshMetadata => '刷新元数据'; + @override String get emptyTrash => '清空回收站'; + @override String emptyingTrash({required Object title}) => '正在清空 “${title}” 的回收站...'; + @override String trashEmptied({required Object title}) => '已清空 “${title}” 的回收站'; + @override String failedToEmptyTrash({required Object error}) => '无法清空回收站: ${error}'; + @override String analyzing({required Object title}) => '正在分析 “${title}”...'; + @override String analysisStarted({required Object title}) => '已开始分析 “${title}”'; + @override String failedToAnalyze({required Object error}) => '无法分析媒体库: ${error}'; + @override String get noLibrariesFound => '未找到媒体库'; + @override String get thisLibraryIsEmpty => '此媒体库为空'; + @override String get all => '全部'; + @override String get clearAll => '全部清除'; + @override String scanLibraryConfirm({required Object title}) => '确定要扫描 “${title}” 吗?'; + @override String analyzeLibraryConfirm({required Object title}) => '确定要分析 “${title}” 吗?'; + @override String refreshMetadataConfirm({required Object title}) => '确定要刷新 “${title}” 的元数据吗?'; + @override String emptyTrashConfirm({required Object title}) => '确定要清空 “${title}” 的回收站吗?'; + @override String get manageLibraries => '管理媒体库'; + @override String get sort => '排序'; + @override String get sortBy => '排序依据'; + @override String get filters => '筛选器'; + @override String loadingLibraryWithCount({required Object count}) => '正在加载媒体库... (已加载 ${count} 个项目)'; + @override String get confirmActionMessage => '确定要执行此操作吗?'; + @override String get showLibrary => '显示媒体库'; + @override String get hideLibrary => '隐藏媒体库'; + @override String get libraryOptions => '媒体库选项'; +} + +// Path: about +class _StringsAboutZh implements _StringsAboutEn { + _StringsAboutZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get title => '关于'; + @override String get openSourceLicenses => '开源许可证'; + @override String versionLabel({required Object version}) => '版本 ${version}'; + @override String get appDescription => '一款精美的 Flutter Plex 客户端'; + @override String get viewLicensesDescription => '查看第三方库的许可证'; +} + +// Path: serverSelection +class _StringsServerSelectionZh implements _StringsServerSelectionEn { + _StringsServerSelectionZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get connectingToServer => '正在连接服务器...'; + @override String get serverDebugCopied => '服务器调试数据已复制到剪贴板'; + @override String get copyDebugData => '复制调试数据'; + @override String get noServersFound => '未找到服务器'; + @override String malformedServerData({required Object count}) => '找到 ${count} 个数据格式错误的服务器。没有可用的有效服务器。'; + @override String get incompleteServerInfo => '某些服务器信息不完整,已被跳过。请检查你的 Plex.tv 账户。'; + @override String get incompleteConnectionInfo => '服务器连接信息不完整。请重试。'; + @override String malformedServerInfo({required Object message}) => '服务器信息格式错误: ${message}'; + @override String get networkConnectionFailed => '网络连接失败。请检查你的网络连接并重试。'; + @override String get authenticationFailed => '验证失败。请重新登录。'; + @override String get plexServiceUnavailable => 'Plex 服务不可用。请稍后重试。'; + @override String failedToLoadServers({required Object error}) => '无法加载服务器: ${error}'; +} + +// Path: hubDetail +class _StringsHubDetailZh implements _StringsHubDetailEn { + _StringsHubDetailZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get title => '标题'; + @override String get releaseYear => '发行年份'; + @override String get dateAdded => '添加日期'; + @override String get rating => '评分'; + @override String get noItemsFound => '未找到项目'; +} + +// Path: logs +class _StringsLogsZh implements _StringsLogsEn { + _StringsLogsZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get title => '日志'; + @override String get clearLogs => '清除日志'; + @override String get copyLogs => '复制日志'; + @override String get exportLogs => '导出日志'; + @override String get noLogsToShow => '没有可显示的日志'; + @override String get error => '错误:'; + @override String get stackTrace => '堆栈跟踪 (Stack Trace):'; +} + +// Path: licenses +class _StringsLicensesZh implements _StringsLicensesEn { + _StringsLicensesZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get relatedPackages => '相关软件包'; + @override String get license => '许可证'; + @override String licenseNumber({required Object number}) => '许可证 ${number}'; + @override String licensesCount({required Object count}) => '${count} 个许可证'; +} + +// Path: navigation +class _StringsNavigationZh implements _StringsNavigationEn { + _StringsNavigationZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get home => '主页'; + @override String get search => '搜索'; + @override String get libraries => '媒体库'; + @override String get settings => '设置'; +} + /// Flat map(s) containing all translations. /// Only for edge cases! For simple maps, use the map function of this library. @@ -2901,6 +4125,330 @@ extension on Translations { } } +extension on _StringsDe { + dynamic _flatMapFunction(String path) { + switch (path) { + case 'app.title': return 'Plezy'; + case 'app.loading': return 'Lädt...'; + case 'auth.signInWithPlex': return 'Mit Plex anmelden'; + case 'auth.showQRCode': return 'QR-Code anzeigen'; + case 'auth.cancel': return 'Abbrechen'; + case 'auth.authenticate': return 'Authentifizieren'; + case 'auth.retry': return 'Erneut versuchen'; + case 'auth.debugEnterToken': return 'Debug: Plex-Token eingeben'; + case 'auth.plexTokenLabel': return 'Plex-Auth-Token'; + case 'auth.plexTokenHint': return 'Plex.tv-Token eingeben'; + case 'auth.authenticationTimeout': return 'Authentifizierung abgelaufen. Bitte erneut versuchen.'; + case 'auth.scanQRCodeInstruction': return 'Diesen QR-Code mit einem bei Plex angemeldeten Gerät scannen, um zu authentifizieren.'; + case 'auth.waitingForAuth': return 'Warte auf Authentifizierung...\nBitte Anmeldung im Browser abschließen.'; + case 'common.cancel': return 'Abbrechen'; + case 'common.save': return 'Speichern'; + case 'common.close': return 'Schließen'; + case 'common.clear': return 'Leeren'; + case 'common.reset': return 'Zurücksetzen'; + case 'common.later': return 'Später'; + case 'common.submit': return 'Senden'; + case 'common.confirm': return 'Bestätigen'; + case 'common.retry': return 'Erneut versuchen'; + case 'common.playNow': return 'Jetzt abspielen'; + case 'common.logout': return 'Abmelden'; + case 'common.online': return 'Online'; + case 'common.offline': return 'Offline'; + case 'common.owned': return 'Eigentum'; + case 'common.shared': return 'Geteilt'; + case 'common.current': return 'AKTUELL'; + case 'common.unknown': return 'Unbekannt'; + case 'common.refresh': return 'Aktualisieren'; + case 'common.yes': return 'Ja'; + case 'common.no': return 'Nein'; + case 'common.server': return 'Server'; + case 'screens.licenses': return 'Lizenzen'; + case 'screens.selectServer': return 'Server auswählen'; + case 'screens.switchProfile': return 'Profil wechseln'; + case 'screens.subtitleStyling': return 'Untertitel-Stil'; + case 'screens.search': return 'Suche'; + case 'screens.logs': return 'Protokolle'; + case 'update.available': return 'Update verfügbar'; + case 'update.versionAvailable': return ({required Object version}) => 'Version ${version} ist verfügbar'; + case 'update.currentVersion': return ({required Object version}) => 'Aktuell: ${version}'; + case 'update.skipVersion': return 'Diese Version überspringen'; + case 'update.viewRelease': return 'Release anzeigen'; + case 'update.latestVersion': return 'Aktuellste Version installiert'; + case 'update.checkFailed': return 'Fehler bei der Updateprüfung'; + case 'settings.title': return 'Einstellungen'; + case 'settings.language': return 'Sprache'; + case 'settings.theme': return 'Theme'; + case 'settings.appearance': return 'Darstellung'; + case 'settings.videoPlayback': return 'Videowiedergabe'; + case 'settings.shufflePlay': return 'Zufallswiedergabe'; + case 'settings.advanced': return 'Erweitert'; + case 'settings.useSeasonPostersDescription': return 'Staffelposter statt Serienposter für Episoden anzeigen'; + case 'settings.showHeroSectionDescription': return 'Bereich mit empfohlenen Inhalten auf der Startseite anzeigen'; + case 'settings.secondsLabel': return 'Sekunden'; + case 'settings.minutesLabel': return 'Minuten'; + case 'settings.secondsShort': return 's'; + case 'settings.minutesShort': return 'm'; + case 'settings.durationHint': return ({required Object min, required Object max}) => 'Dauer eingeben (${min}-${max})'; + case 'settings.systemTheme': return 'System'; + case 'settings.systemThemeDescription': return 'Systemeinstellungen folgen'; + case 'settings.lightTheme': return 'Hell'; + case 'settings.darkTheme': return 'Dunkel'; + case 'settings.libraryDensity': return 'Bibliotheksdichte'; + case 'settings.compact': return 'Kompakt'; + case 'settings.compactDescription': return 'Kleinere Karten, mehr Elemente sichtbar'; + case 'settings.normal': return 'Normal'; + case 'settings.normalDescription': return 'Standardgröße'; + case 'settings.comfortable': return 'Großzügig'; + case 'settings.comfortableDescription': return 'Größere Karten, weniger Elemente sichtbar'; + case 'settings.viewMode': return 'Ansichtsmodus'; + case 'settings.gridView': return 'Raster'; + case 'settings.gridViewDescription': return 'Elemente im Raster anzeigen'; + case 'settings.listView': return 'Liste'; + case 'settings.listViewDescription': return 'Elemente in Listenansicht anzeigen'; + case 'settings.useSeasonPosters': return 'Staffelposter verwenden'; + case 'settings.showHeroSection': return 'Hero-Bereich anzeigen'; + case 'settings.hardwareDecoding': return 'Hardware-Decodierung'; + case 'settings.hardwareDecodingDescription': return 'Hardwarebeschleunigung verwenden, wenn verfügbar'; + case 'settings.bufferSize': return 'Puffergröße'; + case 'settings.bufferSizeMB': return ({required Object size}) => '${size}MB'; + case 'settings.subtitleStyling': return 'Untertitel-Stil'; + case 'settings.subtitleStylingDescription': return 'Aussehen von Untertiteln anpassen'; + case 'settings.smallSkipDuration': return 'Kleine Sprungdauer'; + case 'settings.largeSkipDuration': return 'Große Sprungdauer'; + case 'settings.secondsUnit': return ({required Object seconds}) => '${seconds} Sekunden'; + case 'settings.defaultSleepTimer': return 'Standard-Sleep-Timer'; + case 'settings.minutesUnit': return ({required Object minutes}) => '${minutes} Minuten'; + case 'settings.rememberTrackSelections': return 'Spurauswahl pro Serie/Film merken'; + case 'settings.rememberTrackSelectionsDescription': return 'Audio- und Untertitelsprache automatisch speichern, wenn während der Wiedergabe geändert'; + case 'settings.unwatchedOnly': return 'Nur ungesehene'; + case 'settings.unwatchedOnlyDescription': return 'Nur ungesehene Episoden in die Shuffle-Warteschlange aufnehmen'; + case 'settings.shuffleOrderNavigation': return 'Navigation der Shuffle-Reihenfolge'; + case 'settings.shuffleOrderNavigationDescription': return 'Weiter/Zurück folgt der zufälligen Reihenfolge'; + case 'settings.loopShuffleQueue': return 'Shuffle-Warteschlange wiederholen'; + case 'settings.loopShuffleQueueDescription': return 'Warteschlange neu starten, wenn das Ende erreicht ist'; + case 'settings.videoPlayerControls': return 'Videoplayer-Steuerung'; + case 'settings.keyboardShortcuts': return 'Tastenkürzel'; + case 'settings.keyboardShortcutsDescription': return 'Tastenkürzel anpassen'; + case 'settings.debugLogging': return 'Debug-Protokollierung'; + case 'settings.debugLoggingDescription': return 'Detaillierte Protokolle zur Fehleranalyse aktivieren'; + case 'settings.viewLogs': return 'Protokolle anzeigen'; + case 'settings.viewLogsDescription': return 'App-Protokolle anzeigen'; + case 'settings.clearCache': return 'Cache löschen'; + case 'settings.clearCacheDescription': return 'Löscht alle zwischengespeicherten Bilder und Daten. Die App kann danach langsamer laden.'; + case 'settings.clearCacheSuccess': return 'Cache erfolgreich gelöscht'; + case 'settings.resetSettings': return 'Einstellungen zurücksetzen'; + case 'settings.resetSettingsDescription': return 'Alle Einstellungen auf Standard zurücksetzen. Dies kann nicht rückgängig gemacht werden.'; + case 'settings.resetSettingsSuccess': return 'Einstellungen erfolgreich zurückgesetzt'; + case 'settings.shortcutsReset': return 'Tastenkürzel auf Standard zurückgesetzt'; + case 'settings.about': return 'Über'; + case 'settings.aboutDescription': return 'App-Informationen und Lizenzen'; + case 'settings.updates': return 'Updates'; + case 'settings.updateAvailable': return 'Update verfügbar'; + case 'settings.checkForUpdates': return 'Nach Updates suchen'; + case 'settings.validationErrorEnterNumber': return 'Bitte eine gültige Zahl eingeben'; + case 'settings.validationErrorDuration': return ({required Object min, required Object max, required Object unit}) => 'Dauer muss zwischen ${min} und ${max} ${unit} liegen'; + case 'settings.shortcutAlreadyAssigned': return ({required Object action}) => 'Tastenkürzel bereits zugewiesen an ${action}'; + case 'settings.shortcutUpdated': return ({required Object action}) => 'Tastenkürzel aktualisiert für ${action}'; + case 'search.hint': return 'Filme, Serien, Musik suchen...'; + case 'search.tryDifferentTerm': return 'Anderen Suchbegriff versuchen'; + case 'search.searchYourMedia': return 'In den eigenen Medien suchen'; + case 'search.enterTitleActorOrKeyword': return 'Titel, Schauspieler oder Stichwort eingeben'; + case 'hotkeys.setShortcutFor': return ({required Object actionName}) => 'Tastenkürzel festlegen für ${actionName}'; + case 'hotkeys.clearShortcut': return 'Kürzel löschen'; + case 'pinEntry.enterPin': return 'PIN eingeben'; + case 'pinEntry.showPin': return 'PIN anzeigen'; + case 'pinEntry.hidePin': return 'PIN verbergen'; + case 'fileInfo.title': return 'Dateiinfo'; + case 'fileInfo.video': return 'Video'; + case 'fileInfo.audio': return 'Audio'; + case 'fileInfo.file': return 'Datei'; + case 'fileInfo.advanced': return 'Erweitert'; + case 'fileInfo.codec': return 'Codec'; + case 'fileInfo.resolution': return 'Auflösung'; + case 'fileInfo.bitrate': return 'Bitrate'; + case 'fileInfo.frameRate': return 'Bildrate'; + case 'fileInfo.aspectRatio': return 'Seitenverhältnis'; + case 'fileInfo.profile': return 'Profil'; + case 'fileInfo.bitDepth': return 'Farbtiefe'; + case 'fileInfo.colorSpace': return 'Farbraum'; + case 'fileInfo.colorRange': return 'Farbbereich'; + case 'fileInfo.colorPrimaries': return 'Primärfarben'; + case 'fileInfo.chromaSubsampling': return 'Chroma-Subsampling'; + case 'fileInfo.channels': return 'Kanäle'; + case 'fileInfo.path': return 'Pfad'; + case 'fileInfo.size': return 'Größe'; + case 'fileInfo.container': return 'Container'; + case 'fileInfo.duration': return 'Dauer'; + case 'fileInfo.optimizedForStreaming': return 'Für Streaming optimiert'; + case 'fileInfo.has64bitOffsets': return '64-Bit-Offsets'; + case 'mediaMenu.markAsWatched': return 'Als gesehen markieren'; + case 'mediaMenu.markAsUnwatched': return 'Als ungesehen markieren'; + case 'mediaMenu.removeFromContinueWatching': return 'Aus ‚Weiterschauen‘ entfernen'; + case 'mediaMenu.goToSeries': return 'Zur Serie'; + case 'mediaMenu.goToSeason': return 'Zur Staffel'; + case 'mediaMenu.shufflePlay': return 'Zufallswiedergabe'; + case 'mediaMenu.fileInfo': return 'Dateiinfo'; + case 'tooltips.shufflePlay': return 'Zufallswiedergabe'; + case 'tooltips.markAsWatched': return 'Als gesehen markieren'; + case 'tooltips.markAsUnwatched': return 'Als ungesehen markieren'; + case 'videoControls.audioLabel': return 'Audio'; + case 'videoControls.subtitlesLabel': return 'Untertitel'; + case 'videoControls.resetToZero': return 'Auf 0 ms zurücksetzen'; + case 'videoControls.addTime': return ({required Object amount, required Object unit}) => '+${amount}${unit}'; + case 'videoControls.minusTime': return ({required Object amount, required Object unit}) => '-${amount}${unit}'; + case 'videoControls.playsLater': return ({required Object label}) => '${label} spielt später'; + case 'videoControls.playsEarlier': return ({required Object label}) => '${label} spielt früher'; + case 'videoControls.noOffset': return 'Kein Offset'; + case 'videoControls.letterbox': return 'Letterbox'; + case 'videoControls.fillScreen': return 'Bild füllen'; + case 'videoControls.stretch': return 'Strecken'; + case 'videoControls.lockRotation': return 'Rotation sperren'; + case 'videoControls.unlockRotation': return 'Rotation entsperren'; + case 'userStatus.admin': return 'Admin'; + case 'userStatus.restricted': return 'Eingeschränkt'; + case 'userStatus.protected': return 'Geschützt'; + case 'messages.markedAsWatched': return 'Als gesehen markiert'; + case 'messages.markedAsUnwatched': return 'Als ungesehen markiert'; + case 'messages.removedFromContinueWatching': return 'Aus ‚Weiterschauen‘ entfernt'; + case 'messages.errorLoading': return ({required Object error}) => 'Fehler: ${error}'; + case 'messages.fileInfoNotAvailable': return 'Dateiinfo nicht verfügbar'; + case 'messages.errorLoadingFileInfo': return ({required Object error}) => 'Fehler beim Laden der Dateiinfo: ${error}'; + case 'messages.errorLoadingSeries': return 'Fehler beim Laden der Serie'; + case 'messages.errorLoadingSeason': return 'Fehler beim Laden der Staffel'; + case 'messages.musicNotSupported': return 'Musikwiedergabe wird noch nicht unterstützt'; + case 'messages.logsCleared': return 'Protokolle gelöscht'; + case 'messages.logsCopied': return 'Protokolle in Zwischenablage kopiert'; + case 'messages.noLogsAvailable': return 'Keine Protokolle verfügbar'; + case 'messages.libraryScanning': return ({required Object title}) => 'Scanne „${title}“...'; + case 'messages.libraryScanStarted': return ({required Object title}) => 'Bibliotheksscan gestartet für „${title}“'; + case 'messages.libraryScanFailed': return ({required Object error}) => 'Fehler beim Scannen der Bibliothek: ${error}'; + case 'messages.metadataRefreshing': return ({required Object title}) => 'Metadaten werden aktualisiert für „${title}“...'; + case 'messages.metadataRefreshStarted': return ({required Object title}) => 'Metadaten-Aktualisierung gestartet für „${title}“'; + case 'messages.metadataRefreshFailed': return ({required Object error}) => 'Metadaten konnten nicht aktualisiert werden: ${error}'; + case 'messages.noPlexToken': return 'Kein Plex-Token gefunden. Bitte erneut anmelden.'; + case 'messages.logoutConfirm': return 'Abmeldung wirklich durchführen?'; + case 'messages.noSeasonsFound': return 'Keine Staffeln gefunden'; + case 'messages.noEpisodesFound': return 'Keine Episoden in der ersten Staffel gefunden'; + case 'messages.noEpisodesFoundGeneral': return 'Keine Episoden gefunden'; + case 'messages.noResultsFound': return 'Keine Ergebnisse gefunden'; + case 'messages.sleepTimerSet': return ({required Object label}) => 'Sleep-Timer gesetzt auf ${label}'; + case 'messages.failedToSwitchProfile': return ({required Object displayName}) => 'Profilwechsel zu ${displayName} fehlgeschlagen'; + case 'profile.noUsersAvailable': return 'Keine Benutzer verfügbar'; + case 'subtitlingStyling.stylingOptions': return 'Stiloptionen'; + case 'subtitlingStyling.fontSize': return 'Schriftgröße'; + case 'subtitlingStyling.textColor': return 'Textfarbe'; + case 'subtitlingStyling.borderSize': return 'Rahmengröße'; + case 'subtitlingStyling.borderColor': return 'Rahmenfarbe'; + case 'subtitlingStyling.backgroundOpacity': return 'Hintergrunddeckkraft'; + case 'subtitlingStyling.backgroundColor': return 'Hintergrundfarbe'; + case 'dialog.confirmAction': return 'Aktion bestätigen'; + case 'dialog.areYouSure': return 'Aktion wirklich ausführen?'; + case 'dialog.cancel': return 'Abbrechen'; + case 'dialog.playNow': return 'Jetzt abspielen'; + case 'discover.title': return 'Entdecken'; + case 'discover.switchProfile': return 'Profil wechseln'; + case 'discover.switchServer': return 'Server wechseln'; + case 'discover.logout': return 'Abmelden'; + case 'discover.noContentAvailable': return 'Kein Inhalt verfügbar'; + case 'discover.addMediaToLibraries': return 'Medien zur Bibliothek hinzufügen'; + case 'discover.continueWatching': return 'Weiterschauen'; + case 'discover.recentlyAdded': return 'Kürzlich hinzugefügt'; + case 'discover.play': return 'Abspielen'; + case 'discover.resume': return 'Fortsetzen'; + case 'discover.playEpisode': return ({required Object season, required Object episode}) => 'Spiele S${season}, E${episode}'; + case 'discover.resumeEpisode': return ({required Object season, required Object episode}) => 'Fortsetzen S${season}, E${episode}'; + case 'discover.pause': return 'Pause'; + case 'discover.overview': return 'Übersicht'; + case 'discover.cast': return 'Besetzung'; + case 'discover.episodeCount': return ({required Object count}) => '${count} Episoden'; + case 'discover.watchedProgress': return ({required Object watched, required Object total}) => '${watched}/${total} gesehen'; + case 'discover.movie': return 'Film'; + case 'discover.tvShow': return 'Serie'; + case 'discover.minutesLeft': return ({required Object minutes}) => '${minutes} Min übrig'; + case 'errors.searchFailed': return ({required Object error}) => 'Suche fehlgeschlagen: ${error}'; + case 'errors.connectionTimeout': return ({required Object context}) => 'Zeitüberschreitung beim Laden von ${context}'; + case 'errors.connectionFailed': return 'Verbindung zum Plex-Server fehlgeschlagen'; + case 'errors.failedToLoad': return ({required Object context, required Object error}) => 'Fehler beim Laden von ${context}: ${error}'; + case 'errors.noClientAvailable': return 'Kein Client verfügbar'; + case 'errors.authenticationFailed': return ({required Object error}) => 'Authentifizierung fehlgeschlagen: ${error}'; + case 'errors.couldNotLaunchUrl': return 'Auth-URL konnte nicht geöffnet werden'; + case 'errors.pleaseEnterToken': return 'Bitte Token eingeben'; + case 'errors.invalidToken': return 'Ungültiges Token'; + case 'errors.failedToVerifyToken': return ({required Object error}) => 'Token-Verifizierung fehlgeschlagen: ${error}'; + case 'errors.failedToSwitchProfile': return ({required Object displayName}) => 'Profilwechsel zu ${displayName} fehlgeschlagen'; + case 'errors.connectionFailedGeneric': return 'Verbindung fehlgeschlagen'; + case 'libraries.title': return 'Bibliotheken'; + case 'libraries.scanLibraryFiles': return 'Bibliotheksdateien scannen'; + case 'libraries.scanLibrary': return 'Bibliothek scannen'; + case 'libraries.analyze': return 'Analysieren'; + case 'libraries.analyzeLibrary': return 'Bibliothek analysieren'; + case 'libraries.refreshMetadata': return 'Metadaten aktualisieren'; + case 'libraries.emptyTrash': return 'Papierkorb leeren'; + case 'libraries.emptyingTrash': return ({required Object title}) => 'Papierkorb für „${title}“ wird geleert...'; + case 'libraries.trashEmptied': return ({required Object title}) => 'Papierkorb für „${title}“ geleert'; + case 'libraries.failedToEmptyTrash': return ({required Object error}) => 'Papierkorb konnte nicht geleert werden: ${error}'; + case 'libraries.analyzing': return ({required Object title}) => 'Analysiere „${title}“...'; + case 'libraries.analysisStarted': return ({required Object title}) => 'Analyse gestartet für „${title}“'; + case 'libraries.failedToAnalyze': return ({required Object error}) => 'Analyse der Bibliothek fehlgeschlagen: ${error}'; + case 'libraries.noLibrariesFound': return 'Keine Bibliotheken gefunden'; + case 'libraries.thisLibraryIsEmpty': return 'Diese Bibliothek ist leer'; + case 'libraries.all': return 'Alle'; + case 'libraries.clearAll': return 'Alle löschen'; + case 'libraries.scanLibraryConfirm': return ({required Object title}) => '„${title}“ wirklich scannen?'; + case 'libraries.analyzeLibraryConfirm': return ({required Object title}) => '„${title}“ wirklich analysieren?'; + case 'libraries.refreshMetadataConfirm': return ({required Object title}) => 'Metadaten für „${title}“ wirklich aktualisieren?'; + case 'libraries.emptyTrashConfirm': return ({required Object title}) => 'Papierkorb für „${title}“ wirklich leeren?'; + case 'libraries.manageLibraries': return 'Bibliotheken verwalten'; + case 'libraries.sort': return 'Sortieren'; + case 'libraries.sortBy': return 'Sortieren nach'; + case 'libraries.filters': return 'Filter'; + case 'libraries.loadingLibraryWithCount': return ({required Object count}) => 'Lade Bibliothek... (${count} Elemente geladen)'; + case 'libraries.confirmActionMessage': return 'Aktion wirklich durchführen?'; + case 'libraries.showLibrary': return 'Bibliothek anzeigen'; + case 'libraries.hideLibrary': return 'Bibliothek ausblenden'; + case 'libraries.libraryOptions': return 'Bibliotheksoptionen'; + case 'about.title': return 'Über'; + case 'about.openSourceLicenses': return 'Open-Source-Lizenzen'; + case 'about.versionLabel': return ({required Object version}) => 'Version ${version}'; + case 'about.appDescription': return 'Ein schöner Plex-Client für Flutter'; + case 'about.viewLicensesDescription': return 'Lizenzen von Drittanbieter-Bibliotheken anzeigen'; + case 'serverSelection.connectingToServer': return 'Verbinde mit Server...'; + case 'serverSelection.serverDebugCopied': return 'Server-Debugdaten kopiert'; + case 'serverSelection.copyDebugData': return 'Debugdaten kopieren'; + case 'serverSelection.noServersFound': return 'Keine Server gefunden'; + case 'serverSelection.malformedServerData': return ({required Object count}) => '${count} Server mit fehlerhaften Daten gefunden. Keine gültigen Server verfügbar.'; + case 'serverSelection.incompleteServerInfo': return 'Einige Serverinformationen sind unvollständig und wurden übersprungen. Plex.tv-Konto prüfen.'; + case 'serverSelection.incompleteConnectionInfo': return 'Serververbindungsinformationen unvollständig. Bitte erneut versuchen.'; + case 'serverSelection.malformedServerInfo': return ({required Object message}) => 'Serverinformationen fehlerhaft: ${message}'; + case 'serverSelection.networkConnectionFailed': return 'Netzwerkverbindung fehlgeschlagen. Internetverbindung prüfen und erneut versuchen.'; + case 'serverSelection.authenticationFailed': return 'Authentifizierung fehlgeschlagen. Bitte erneut anmelden.'; + case 'serverSelection.plexServiceUnavailable': return 'Plex-Dienst nicht verfügbar. Bitte später erneut versuchen.'; + case 'serverSelection.failedToLoadServers': return ({required Object error}) => 'Server konnten nicht geladen werden: ${error}'; + case 'hubDetail.title': return 'Titel'; + case 'hubDetail.releaseYear': return 'Erscheinungsjahr'; + case 'hubDetail.dateAdded': return 'Hinzugefügt am'; + case 'hubDetail.rating': return 'Bewertung'; + case 'hubDetail.noItemsFound': return 'Keine Elemente gefunden'; + case 'logs.title': return 'Protokolle'; + case 'logs.clearLogs': return 'Protokolle löschen'; + case 'logs.copyLogs': return 'Protokolle kopieren'; + case 'logs.exportLogs': return 'Protokolle exportieren'; + case 'logs.noLogsToShow': return 'Keine Protokolle zum Anzeigen'; + case 'logs.error': return 'Fehler:'; + case 'logs.stackTrace': return 'Stacktrace:'; + case 'licenses.relatedPackages': return 'Verwandte Pakete'; + case 'licenses.license': return 'Lizenz'; + case 'licenses.licenseNumber': return ({required Object number}) => 'Lizenz ${number}'; + case 'licenses.licensesCount': return ({required Object count}) => '${count} Lizenzen'; + case 'navigation.home': return 'Start'; + case 'navigation.search': return 'Suche'; + case 'navigation.libraries': return 'Bibliotheken'; + case 'navigation.settings': return 'Einstellungen'; + default: return null; + } + } +} + extension on _StringsIt { dynamic _flatMapFunction(String path) { switch (path) { @@ -3872,3 +5420,325 @@ extension on _StringsSv { } } } + +extension on _StringsZh { + dynamic _flatMapFunction(String path) { + switch (path) { + case 'app.title': return 'Plezy'; + case 'app.loading': return '加载中...'; + case 'auth.signInWithPlex': return '使用 Plex 登录'; + case 'auth.showQRCode': return '显示二维码'; + case 'auth.cancel': return '取消'; + case 'auth.authenticate': return '验证'; + case 'auth.retry': return '重试'; + case 'auth.debugEnterToken': return '调试:输入 Plex Token'; + case 'auth.plexTokenLabel': return 'Plex 授权令牌 (Auth Token)'; + case 'auth.plexTokenHint': return '输入你的 Plex.tv 令牌'; + case 'auth.authenticationTimeout': return '验证超时。请重试。'; + case 'auth.scanQRCodeInstruction': return '请使用已登录 Plex 的设备扫描此二维码进行验证。'; + case 'auth.waitingForAuth': return '等待验证中...\n请在你的浏览器中完成登录。'; + case 'common.cancel': return '取消'; + case 'common.save': return '保存'; + case 'common.close': return '关闭'; + case 'common.clear': return '清除'; + case 'common.reset': return '重置'; + case 'common.later': return '稍后'; + case 'common.submit': return '提交'; + case 'common.confirm': return '确认'; + case 'common.retry': return '重试'; + case 'common.playNow': return '立即播放'; + case 'common.logout': return '登出'; + case 'common.online': return '在线'; + case 'common.offline': return '离线'; + case 'common.owned': return '拥有'; + case 'common.shared': return '共享'; + case 'common.current': return '当前'; + case 'common.unknown': return '未知'; + case 'common.refresh': return '刷新'; + case 'common.yes': return '是'; + case 'common.no': return '否'; + case 'common.server': return '服务器'; + case 'screens.licenses': return '许可证'; + case 'screens.selectServer': return '选择服务器'; + case 'screens.switchProfile': return '切换配置文件'; + case 'screens.subtitleStyling': return '字幕样式'; + case 'screens.search': return '搜索'; + case 'screens.logs': return '日志'; + case 'update.available': return '有可用更新'; + case 'update.versionAvailable': return ({required Object version}) => '版本 ${version} 已发布'; + case 'update.currentVersion': return ({required Object version}) => '当前版本: ${version}'; + case 'update.skipVersion': return '跳过此版本'; + case 'update.viewRelease': return '查看发布详情'; + case 'update.latestVersion': return '已安装的版本是可用的最新版本'; + case 'update.checkFailed': return '无法检查更新'; + case 'settings.title': return '设置'; + case 'settings.language': return '语言'; + case 'settings.theme': return '主题'; + case 'settings.appearance': return '外观'; + case 'settings.videoPlayback': return '视频播放'; + case 'settings.shufflePlay': return '随机播放'; + case 'settings.advanced': return '高级'; + case 'settings.useSeasonPostersDescription': return '为剧集显示季海报而非剧集海报'; + case 'settings.showHeroSectionDescription': return '在主屏幕上显示精选内容轮播区'; + case 'settings.secondsLabel': return '秒'; + case 'settings.minutesLabel': return '分钟'; + case 'settings.secondsShort': return 's'; + case 'settings.minutesShort': return 'm'; + case 'settings.durationHint': return ({required Object min, required Object max}) => '输入时长 (${min}-${max})'; + case 'settings.systemTheme': return '系统'; + case 'settings.systemThemeDescription': return '跟随系统设置'; + case 'settings.lightTheme': return '浅色'; + case 'settings.darkTheme': return '深色'; + case 'settings.libraryDensity': return '媒体库密度'; + case 'settings.compact': return '紧凑'; + case 'settings.compactDescription': return '卡片更小,显示更多项目'; + case 'settings.normal': return '标准'; + case 'settings.normalDescription': return '默认尺寸'; + case 'settings.comfortable': return '舒适'; + case 'settings.comfortableDescription': return '卡片更大,显示更少项目'; + case 'settings.viewMode': return '视图模式'; + case 'settings.gridView': return '网格视图'; + case 'settings.gridViewDescription': return '以网格布局显示项目'; + case 'settings.listView': return '列表视图'; + case 'settings.listViewDescription': return '以列表布局显示项目'; + case 'settings.useSeasonPosters': return '使用季海报'; + case 'settings.showHeroSection': return '显示主要精选区'; + case 'settings.hardwareDecoding': return '硬件解码'; + case 'settings.hardwareDecodingDescription': return '如果可用,使用硬件加速'; + case 'settings.bufferSize': return '缓冲区大小'; + case 'settings.bufferSizeMB': return ({required Object size}) => '${size}MB'; + case 'settings.subtitleStyling': return '字幕样式'; + case 'settings.subtitleStylingDescription': return '调整字幕外观'; + case 'settings.smallSkipDuration': return '短跳过时长'; + case 'settings.largeSkipDuration': return '长跳过时长'; + case 'settings.secondsUnit': return ({required Object seconds}) => '${seconds} 秒'; + case 'settings.defaultSleepTimer': return '默认睡眠定时器'; + case 'settings.minutesUnit': return ({required Object minutes}) => '${minutes} 分钟'; + case 'settings.unwatchedOnly': return '仅未观看'; + case 'settings.unwatchedOnlyDescription': return '随机队列中仅包含未观看的剧集'; + case 'settings.shuffleOrderNavigation': return '随机顺序导航'; + case 'settings.shuffleOrderNavigationDescription': return '下一集/上一集按钮遵循随机播放顺序'; + case 'settings.loopShuffleQueue': return '循环随机队列'; + case 'settings.loopShuffleQueueDescription': return '在队列结束时重新开始'; + case 'settings.videoPlayerControls': return '视频播放器控制'; + case 'settings.keyboardShortcuts': return '键盘快捷键'; + case 'settings.keyboardShortcutsDescription': return '自定义键盘快捷键'; + case 'settings.debugLogging': return '调试日志'; + case 'settings.debugLoggingDescription': return '启用详细日志记录以便故障排除'; + case 'settings.viewLogs': return '查看日志'; + case 'settings.viewLogsDescription': return '查看应用程序日志'; + case 'settings.clearCache': return '清除缓存'; + case 'settings.clearCacheDescription': return '这将清除所有缓存的图片和数据。清除缓存后,应用程序加载内容可能会变慢。'; + case 'settings.clearCacheSuccess': return '缓存清除成功'; + case 'settings.resetSettings': return '重置设置'; + case 'settings.resetSettingsDescription': return '这会将所有设置重置为其默认值。此操作无法撤销。'; + case 'settings.resetSettingsSuccess': return '设置重置成功'; + case 'settings.shortcutsReset': return '快捷键已重置为默认值'; + case 'settings.about': return '关于'; + case 'settings.aboutDescription': return '应用程序信息和许可证'; + case 'settings.updates': return '更新'; + case 'settings.updateAvailable': return '有可用更新'; + case 'settings.checkForUpdates': return '检查更新'; + case 'settings.validationErrorEnterNumber': return '请输入一个有效的数字'; + case 'settings.validationErrorDuration': return ({required Object min, required Object max, required Object unit}) => '时长必须介于 ${min} 和 ${max} ${unit} 之间'; + case 'settings.shortcutAlreadyAssigned': return ({required Object action}) => '快捷键已被分配给 ${action}'; + case 'settings.shortcutUpdated': return ({required Object action}) => '快捷键已为 ${action} 更新'; + case 'search.hint': return '搜索电影、系列、音乐...'; + case 'search.tryDifferentTerm': return '尝试不同的搜索词'; + case 'search.searchYourMedia': return '搜索媒体'; + case 'search.enterTitleActorOrKeyword': return '输入标题、演员或关键词'; + case 'hotkeys.setShortcutFor': return ({required Object actionName}) => '为 ${actionName} 设置快捷键'; + case 'hotkeys.clearShortcut': return '清除快捷键'; + case 'pinEntry.enterPin': return '输入 PIN'; + case 'pinEntry.showPin': return '显示 PIN'; + case 'pinEntry.hidePin': return '隐藏 PIN'; + case 'fileInfo.title': return '文件信息'; + case 'fileInfo.video': return '视频'; + case 'fileInfo.audio': return '音频'; + case 'fileInfo.file': return '文件'; + case 'fileInfo.advanced': return '高级'; + case 'fileInfo.codec': return '编解码器'; + case 'fileInfo.resolution': return '分辨率'; + case 'fileInfo.bitrate': return '比特率'; + case 'fileInfo.frameRate': return '帧率'; + case 'fileInfo.aspectRatio': return '宽高比'; + case 'fileInfo.profile': return '配置文件'; + case 'fileInfo.bitDepth': return '位深度'; + case 'fileInfo.colorSpace': return '色彩空间'; + case 'fileInfo.colorRange': return '色彩范围'; + case 'fileInfo.colorPrimaries': return '颜色原色'; + case 'fileInfo.chromaSubsampling': return '色度子采样'; + case 'fileInfo.channels': return '声道'; + case 'fileInfo.path': return '路径'; + case 'fileInfo.size': return '大小'; + case 'fileInfo.container': return '容器'; + case 'fileInfo.duration': return '时长'; + case 'fileInfo.optimizedForStreaming': return '已优化用于流媒体'; + case 'fileInfo.has64bitOffsets': return '64位偏移量'; + case 'mediaMenu.markAsWatched': return '标记为已观看'; + case 'mediaMenu.markAsUnwatched': return '标记为未观看'; + case 'mediaMenu.removeFromContinueWatching': return '从继续观看中移除'; + case 'mediaMenu.goToSeries': return '转到系列'; + case 'mediaMenu.goToSeason': return '转到季'; + case 'mediaMenu.shufflePlay': return '随机播放'; + case 'mediaMenu.fileInfo': return '文件信息'; + case 'tooltips.shufflePlay': return '随机播放'; + case 'tooltips.markAsWatched': return '标记为已观看'; + case 'tooltips.markAsUnwatched': return '标记为未观看'; + case 'videoControls.audioLabel': return '音频'; + case 'videoControls.subtitlesLabel': return '字幕'; + case 'videoControls.resetToZero': return '重置为 0ms'; + case 'videoControls.addTime': return ({required Object amount, required Object unit}) => '+${amount}${unit}'; + case 'videoControls.minusTime': return ({required Object amount, required Object unit}) => '-${amount}${unit}'; + case 'videoControls.playsLater': return ({required Object label}) => '${label} 播放较晚'; + case 'videoControls.playsEarlier': return ({required Object label}) => '${label} 播放较早'; + case 'videoControls.noOffset': return '无偏移'; + case 'videoControls.letterbox': return '信箱模式(Letterbox)'; + case 'videoControls.fillScreen': return '填充屏幕'; + case 'videoControls.stretch': return '拉伸'; + case 'videoControls.lockRotation': return '锁定旋转'; + case 'videoControls.unlockRotation': return '解锁旋转'; + case 'userStatus.admin': return '管理员'; + case 'userStatus.restricted': return '受限'; + case 'userStatus.protected': return '受保护'; + case 'messages.markedAsWatched': return '已标记为已观看'; + case 'messages.markedAsUnwatched': return '已标记为未观看'; + case 'messages.removedFromContinueWatching': return '已从继续观看中移除'; + case 'messages.errorLoading': return ({required Object error}) => '错误: ${error}'; + case 'messages.fileInfoNotAvailable': return '文件信息不可用'; + case 'messages.errorLoadingFileInfo': return ({required Object error}) => '加载文件信息时出错: ${error}'; + case 'messages.errorLoadingSeries': return '加载系列时出错'; + case 'messages.errorLoadingSeason': return '加载季时出错'; + case 'messages.musicNotSupported': return '尚不支持播放音乐'; + case 'messages.logsCleared': return '日志已清除'; + case 'messages.logsCopied': return '日志已复制到剪贴板'; + case 'messages.noLogsAvailable': return '没有可用日志'; + case 'messages.libraryScanning': return ({required Object title}) => '正在扫描 “${title}”...'; + case 'messages.libraryScanStarted': return ({required Object title}) => '已开始扫描 “${title}” 媒体库'; + case 'messages.libraryScanFailed': return ({required Object error}) => '无法扫描媒体库: ${error}'; + case 'messages.metadataRefreshing': return ({required Object title}) => '正在刷新 “${title}” 的元数据...'; + case 'messages.metadataRefreshStarted': return ({required Object title}) => '已开始刷新 “${title}” 的元数据'; + case 'messages.metadataRefreshFailed': return ({required Object error}) => '无法刷新元数据: ${error}'; + case 'messages.noPlexToken': return '未找到 Plex 令牌。请重新登录。'; + case 'messages.logoutConfirm': return '你确定要登出吗?'; + case 'messages.noSeasonsFound': return '未找到季'; + case 'messages.noEpisodesFound': return '在第一季中未找到剧集'; + case 'messages.noEpisodesFoundGeneral': return '未找到剧集'; + case 'messages.noResultsFound': return '未找到结果'; + case 'messages.sleepTimerSet': return ({required Object label}) => '睡眠定时器已设置为 ${label}'; + case 'messages.failedToSwitchProfile': return ({required Object displayName}) => '无法切换到 ${displayName}'; + case 'profile.noUsersAvailable': return '没有可用用户'; + case 'subtitlingStyling.stylingOptions': return '样式选项'; + case 'subtitlingStyling.fontSize': return '字号'; + case 'subtitlingStyling.textColor': return '文本颜色'; + case 'subtitlingStyling.borderSize': return '边框大小'; + case 'subtitlingStyling.borderColor': return '边框颜色'; + case 'subtitlingStyling.backgroundOpacity': return '背景不透明度'; + case 'subtitlingStyling.backgroundColor': return '背景颜色'; + case 'dialog.confirmAction': return '确认操作'; + case 'dialog.areYouSure': return '确定要执行此操作吗?'; + case 'dialog.cancel': return '取消'; + case 'dialog.playNow': return '立即播放'; + case 'discover.title': return '发现'; + case 'discover.switchProfile': return '切换配置文件'; + case 'discover.switchServer': return '切换服务器'; + case 'discover.logout': return '登出'; + case 'discover.noContentAvailable': return '没有可用内容'; + case 'discover.addMediaToLibraries': return '请向你的媒体库添加一些媒体'; + case 'discover.continueWatching': return '继续观看'; + case 'discover.recentlyAdded': return '最近添加'; + case 'discover.play': return '播放'; + case 'discover.resume': return '继续'; + case 'discover.playEpisode': return ({required Object season, required Object episode}) => '播放 S${season}, E${episode}'; + case 'discover.resumeEpisode': return ({required Object season, required Object episode}) => '继续 S${season}, E${episode}'; + case 'discover.pause': return '暂停'; + case 'discover.overview': return '概述'; + case 'discover.cast': return '演员表'; + case 'discover.episodeCount': return ({required Object count}) => '${count} 集'; + case 'discover.watchedProgress': return ({required Object watched, required Object total}) => '已观看 ${watched}/${total} 集'; + case 'discover.movie': return '电影'; + case 'discover.tvShow': return '电视剧'; + case 'discover.minutesLeft': return ({required Object minutes}) => '剩余 ${minutes} 分钟'; + case 'errors.searchFailed': return ({required Object error}) => '搜索失败: ${error}'; + case 'errors.connectionTimeout': return ({required Object context}) => '加载 ${context} 时连接超时'; + case 'errors.connectionFailed': return '无法连接到 Plex 服务器'; + case 'errors.failedToLoad': return ({required Object context, required Object error}) => '无法加载 ${context}: ${error}'; + case 'errors.noClientAvailable': return '没有可用客户端'; + case 'errors.authenticationFailed': return ({required Object error}) => '验证失败: ${error}'; + case 'errors.couldNotLaunchUrl': return '无法打开授权 URL'; + case 'errors.pleaseEnterToken': return '请输入一个令牌'; + case 'errors.invalidToken': return '令牌无效'; + case 'errors.failedToVerifyToken': return ({required Object error}) => '无法验证令牌: ${error}'; + case 'errors.failedToSwitchProfile': return ({required Object displayName}) => '无法切换到 ${displayName}'; + case 'errors.connectionFailedGeneric': return '连接失败'; + case 'libraries.title': return '媒体库'; + case 'libraries.scanLibraryFiles': return '扫描媒体库文件'; + case 'libraries.scanLibrary': return '扫描媒体库'; + case 'libraries.analyze': return '分析'; + case 'libraries.analyzeLibrary': return '分析媒体库'; + case 'libraries.refreshMetadata': return '刷新元数据'; + case 'libraries.emptyTrash': return '清空回收站'; + case 'libraries.emptyingTrash': return ({required Object title}) => '正在清空 “${title}” 的回收站...'; + case 'libraries.trashEmptied': return ({required Object title}) => '已清空 “${title}” 的回收站'; + case 'libraries.failedToEmptyTrash': return ({required Object error}) => '无法清空回收站: ${error}'; + case 'libraries.analyzing': return ({required Object title}) => '正在分析 “${title}”...'; + case 'libraries.analysisStarted': return ({required Object title}) => '已开始分析 “${title}”'; + case 'libraries.failedToAnalyze': return ({required Object error}) => '无法分析媒体库: ${error}'; + case 'libraries.noLibrariesFound': return '未找到媒体库'; + case 'libraries.thisLibraryIsEmpty': return '此媒体库为空'; + case 'libraries.all': return '全部'; + case 'libraries.clearAll': return '全部清除'; + case 'libraries.scanLibraryConfirm': return ({required Object title}) => '确定要扫描 “${title}” 吗?'; + case 'libraries.analyzeLibraryConfirm': return ({required Object title}) => '确定要分析 “${title}” 吗?'; + case 'libraries.refreshMetadataConfirm': return ({required Object title}) => '确定要刷新 “${title}” 的元数据吗?'; + case 'libraries.emptyTrashConfirm': return ({required Object title}) => '确定要清空 “${title}” 的回收站吗?'; + case 'libraries.manageLibraries': return '管理媒体库'; + case 'libraries.sort': return '排序'; + case 'libraries.sortBy': return '排序依据'; + case 'libraries.filters': return '筛选器'; + case 'libraries.loadingLibraryWithCount': return ({required Object count}) => '正在加载媒体库... (已加载 ${count} 个项目)'; + case 'libraries.confirmActionMessage': return '确定要执行此操作吗?'; + case 'libraries.showLibrary': return '显示媒体库'; + case 'libraries.hideLibrary': return '隐藏媒体库'; + case 'libraries.libraryOptions': return '媒体库选项'; + case 'about.title': return '关于'; + case 'about.openSourceLicenses': return '开源许可证'; + case 'about.versionLabel': return ({required Object version}) => '版本 ${version}'; + case 'about.appDescription': return '一款精美的 Flutter Plex 客户端'; + case 'about.viewLicensesDescription': return '查看第三方库的许可证'; + case 'serverSelection.connectingToServer': return '正在连接服务器...'; + case 'serverSelection.serverDebugCopied': return '服务器调试数据已复制到剪贴板'; + case 'serverSelection.copyDebugData': return '复制调试数据'; + case 'serverSelection.noServersFound': return '未找到服务器'; + case 'serverSelection.malformedServerData': return ({required Object count}) => '找到 ${count} 个数据格式错误的服务器。没有可用的有效服务器。'; + case 'serverSelection.incompleteServerInfo': return '某些服务器信息不完整,已被跳过。请检查你的 Plex.tv 账户。'; + case 'serverSelection.incompleteConnectionInfo': return '服务器连接信息不完整。请重试。'; + case 'serverSelection.malformedServerInfo': return ({required Object message}) => '服务器信息格式错误: ${message}'; + case 'serverSelection.networkConnectionFailed': return '网络连接失败。请检查你的网络连接并重试。'; + case 'serverSelection.authenticationFailed': return '验证失败。请重新登录。'; + case 'serverSelection.plexServiceUnavailable': return 'Plex 服务不可用。请稍后重试。'; + case 'serverSelection.failedToLoadServers': return ({required Object error}) => '无法加载服务器: ${error}'; + case 'hubDetail.title': return '标题'; + case 'hubDetail.releaseYear': return '发行年份'; + case 'hubDetail.dateAdded': return '添加日期'; + case 'hubDetail.rating': return '评分'; + case 'hubDetail.noItemsFound': return '未找到项目'; + case 'logs.title': return '日志'; + case 'logs.clearLogs': return '清除日志'; + case 'logs.copyLogs': return '复制日志'; + case 'logs.exportLogs': return '导出日志'; + case 'logs.noLogsToShow': return '没有可显示的日志'; + case 'logs.error': return '错误:'; + case 'logs.stackTrace': return '堆栈跟踪 (Stack Trace):'; + case 'licenses.relatedPackages': return '相关软件包'; + case 'licenses.license': return '许可证'; + case 'licenses.licenseNumber': return ({required Object number}) => '许可证 ${number}'; + case 'licenses.licensesCount': return ({required Object count}) => '${count} 个许可证'; + case 'navigation.home': return '主页'; + case 'navigation.search': return '搜索'; + case 'navigation.libraries': return '媒体库'; + case 'navigation.settings': return '设置'; + default: return null; + } + } +} diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 9a909197..88cc672b 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -885,6 +885,10 @@ class _SettingsScreenState extends State { return 'Italiano'; case AppLocale.nl: return 'Nederlands'; + case AppLocale.de: + return 'Deutsch'; + case AppLocale.zh: + return '中文'; } } From ed478d1ed0b261088abeb31fef9f8786bd0449b1 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 15 Nov 2025 01:13:09 +0100 Subject: [PATCH 15/29] i18n(zh): translate 2 new strings --- lib/i18n/strings.g.dart | 8 ++++++-- lib/i18n/strings_zh.i18n.json | 2 ++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index fd030098..61e360cf 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 6 -/// Strings: 1894 (315 per locale) +/// Strings: 1896 (316 per locale) /// -/// Built on 2025-11-15 at 00:10 UTC +/// Built on 2025-11-15 at 00:12 UTC // coverage:ignore-file // ignore_for_file: type=lint @@ -3383,6 +3383,8 @@ class _StringsSettingsZh implements _StringsSettingsEn { @override String secondsUnit({required Object seconds}) => '${seconds} 秒'; @override String get defaultSleepTimer => '默认睡眠定时器'; @override String minutesUnit({required Object minutes}) => '${minutes} 分钟'; + @override String get rememberTrackSelections => '记住每个剧集/电影的音轨选择'; + @override String get rememberTrackSelectionsDescription => '在播放过程中更改音轨时自动保存音频和字幕语言偏好'; @override String get unwatchedOnly => '仅未观看'; @override String get unwatchedOnlyDescription => '随机队列中仅包含未观看的剧集'; @override String get shuffleOrderNavigation => '随机顺序导航'; @@ -5514,6 +5516,8 @@ extension on _StringsZh { case 'settings.secondsUnit': return ({required Object seconds}) => '${seconds} 秒'; case 'settings.defaultSleepTimer': return '默认睡眠定时器'; case 'settings.minutesUnit': return ({required Object minutes}) => '${minutes} 分钟'; + case 'settings.rememberTrackSelections': return '记住每个剧集/电影的音轨选择'; + case 'settings.rememberTrackSelectionsDescription': return '在播放过程中更改音轨时自动保存音频和字幕语言偏好'; case 'settings.unwatchedOnly': return '仅未观看'; case 'settings.unwatchedOnlyDescription': return '随机队列中仅包含未观看的剧集'; case 'settings.shuffleOrderNavigation': return '随机顺序导航'; diff --git a/lib/i18n/strings_zh.i18n.json b/lib/i18n/strings_zh.i18n.json index 23ae4ed1..34eae804 100644 --- a/lib/i18n/strings_zh.i18n.json +++ b/lib/i18n/strings_zh.i18n.json @@ -100,6 +100,8 @@ "secondsUnit": "${seconds} 秒", "defaultSleepTimer": "默认睡眠定时器", "minutesUnit": "${minutes} 分钟", + "rememberTrackSelections": "记住每个剧集/电影的音轨选择", + "rememberTrackSelectionsDescription": "在播放过程中更改音轨时自动保存音频和字幕语言偏好", "unwatchedOnly": "仅未观看", "unwatchedOnlyDescription": "随机队列中仅包含未观看的剧集", "shuffleOrderNavigation": "随机顺序导航", From 232459017560e2fcabe537988b174aed245c79bb Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 15 Nov 2025 04:07:08 +0100 Subject: [PATCH 16/29] feat: playlists --- lib/client/plex_client.dart | 273 ++++++++++- lib/config/plex_config.dart | 6 + lib/i18n/strings.g.dart | 472 +++++++++++++++++- lib/i18n/strings.i18n.json | 36 ++ lib/i18n/strings_de.i18n.json | 36 ++ lib/i18n/strings_it.i18n.json | 36 ++ lib/i18n/strings_nl.i18n.json | 36 ++ lib/i18n/strings_sv.i18n.json | 36 ++ lib/i18n/strings_zh.i18n.json | 36 ++ lib/models/plex_metadata.dart | 4 + lib/models/plex_metadata.g.dart | 2 + lib/models/plex_playlist.dart | 66 +++ lib/models/plex_playlist.g.dart | 48 ++ lib/providers/playback_state_provider.dart | 102 ++-- lib/screens/libraries_screen.dart | 13 + lib/screens/playlist_detail_screen.dart | 517 ++++++++++++++++++++ lib/screens/playlists_screen.dart | 404 +++++++++++++++ lib/screens/video_player_screen.dart | 44 +- lib/services/server_connection_service.dart | 12 + lib/widgets/media_context_menu.dart | 215 ++++++++ lib/widgets/playlist_item_card.dart | 199 ++++++++ macos/Podfile.lock | 6 + 22 files changed, 2539 insertions(+), 60 deletions(-) create mode 100644 lib/models/plex_playlist.dart create mode 100644 lib/models/plex_playlist.g.dart create mode 100644 lib/screens/playlist_detail_screen.dart create mode 100644 lib/screens/playlists_screen.dart create mode 100644 lib/widgets/playlist_item_card.dart diff --git a/lib/client/plex_client.dart b/lib/client/plex_client.dart index b01e62f3..0b31a98a 100644 --- a/lib/client/plex_client.dart +++ b/lib/client/plex_client.dart @@ -10,6 +10,7 @@ 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_playlist.dart'; import '../models/plex_sort.dart'; import '../models/plex_video_playback_data.dart'; import '../network/endpoint_failover_interceptor.dart'; @@ -43,11 +44,11 @@ class PlexClient { this.config, { List? prioritizedEndpoints, Future Function(String newBaseUrl)? onEndpointChanged, - }) : _endpointManager = (prioritizedEndpoints != null && - prioritizedEndpoints.isNotEmpty) - ? EndpointFailoverManager(prioritizedEndpoints) - : null, - _onEndpointChanged = onEndpointChanged { + }) : _endpointManager = + (prioritizedEndpoints != null && prioritizedEndpoints.isNotEmpty) + ? EndpointFailoverManager(prioritizedEndpoints) + : null, + _onEndpointChanged = onEndpointChanged { LogRedactionManager.registerServerUrl(config.baseUrl); LogRedactionManager.registerToken(config.token); @@ -104,12 +105,10 @@ class PlexClient { return; } - final targetBaseUrl = - switchToFirst ? prioritizedEndpoints.first : config.baseUrl; - _endpointManager.reset( - prioritizedEndpoints, - currentBaseUrl: targetBaseUrl, - ); + final targetBaseUrl = switchToFirst + ? prioritizedEndpoints.first + : config.baseUrl; + _endpointManager.reset(prioritizedEndpoints, currentBaseUrl: targetBaseUrl); if (switchToFirst && targetBaseUrl != config.baseUrl) { await _handleEndpointSwitch(targetBaseUrl); @@ -304,6 +303,30 @@ class PlexClient { return _extractSingleMetadata(response); } + /// Get the server's machine identifier + Future getMachineIdentifier() async { + try { + final response = await _dio.get('/'); + final container = _getMediaContainer(response); + if (container == null) return null; + return container['machineIdentifier'] as String?; + } catch (e) { + appLogger.e('Failed to get machine identifier', error: e); + return null; + } + } + + /// Build a proper metadata URI for adding to playlists + /// Returns URI in format: server://{machineId}/com.plexapp.plugins.library/library/metadata/{ratingKey} + Future buildMetadataUri(String ratingKey) async { + // Use cached machine identifier from config if available + final machineId = config.machineIdentifier ?? await getMachineIdentifier(); + if (machineId == null) { + throw Exception('Could not get server machine identifier'); + } + return 'server://$machineId/com.plexapp.plugins.library/library/metadata/$ratingKey'; + } + /// Get metadata by rating key with images (includes clearLogo and OnDeck) Future> getMetadataWithImagesAndOnDeck( String ratingKey, @@ -1233,6 +1256,234 @@ class PlexClient { } } + /// Get all playlists + /// Filters by playlistType=video by default + /// Set smart to true/false to filter smart playlists, or null for all + Future> getPlaylists({ + String playlistType = 'video', + bool? smart, + }) async { + try { + final queryParams = {'playlistType': playlistType}; + if (smart != null) { + queryParams['smart'] = smart ? '1' : '0'; + } + + final response = await _dio.get( + '/playlists', + queryParameters: queryParams, + ); + final container = _getMediaContainer(response); + + if (container == null || container['Metadata'] == null) { + return []; + } + + final List metadata = container['Metadata'] as List; + + if (metadata.isEmpty) { + return []; + } + + return metadata + .map((item) => PlexPlaylist.fromJson(item as Map)) + .toList(); + } catch (e) { + appLogger.e('Failed to get playlists: $e'); + return []; + } + } + + /// Get playlist metadata by playlist ID + /// Returns the playlist details (not the items) + Future getPlaylistMetadata(String playlistId) async { + try { + final response = await _dio.get('/playlists/$playlistId'); + final container = _getMediaContainer(response); + + if (container == null || container['Metadata'] == null) { + return null; + } + + final List metadata = container['Metadata'] as List; + + if (metadata.isEmpty) { + return null; + } + + return PlexPlaylist.fromJson(metadata.first as Map); + } catch (e) { + appLogger.e('Failed to get playlist metadata: $e'); + return null; + } + } + + /// Create a new playlist + /// [title] - Name of the playlist + /// [uri] - Optional comma-separated list of item URIs to add (e.g., "server://uuid/com.plexapp.plugins.library/library/metadata/1234") + /// [playQueueId] - Optional play queue ID to create playlist from + Future createPlaylist({ + required String title, + String? uri, + int? playQueueId, + }) async { + try { + final queryParams = { + 'type': 'video', + 'title': title, + 'smart': '0', + }; + + if (uri != null) { + queryParams['uri'] = uri; + } + if (playQueueId != null) { + queryParams['playQueueID'] = playQueueId.toString(); + } + + final response = await _dio.post( + '/playlists', + queryParameters: queryParams, + ); + final container = _getMediaContainer(response); + + if (container == null || container['Metadata'] == null) { + return null; + } + + final List metadata = container['Metadata'] as List; + + if (metadata.isEmpty) { + return null; + } + + return PlexPlaylist.fromJson(metadata.first as Map); + } catch (e) { + appLogger.e('Failed to create playlist: $e'); + return null; + } + } + + /// Delete a playlist + Future deletePlaylist(String playlistId) async { + try { + await _dio.delete('/playlists/$playlistId'); + return true; + } catch (e) { + appLogger.e('Failed to delete playlist: $e'); + return false; + } + } + + /// Add items to a playlist + /// [playlistId] - The playlist to add items to + /// [uri] - Comma-separated list of item URIs to add + Future addToPlaylist({ + required String playlistId, + required String uri, + }) async { + try { + appLogger.d( + 'Adding to playlist $playlistId with URI: ${uri.substring(0, uri.length > 100 ? 100 : uri.length)}${uri.length > 100 ? "..." : ""}', + ); + final response = await _dio.put( + '/playlists/$playlistId/items', + queryParameters: {'uri': uri}, + ); + appLogger.d('Add to playlist response status: ${response.statusCode}'); + return response.statusCode == 200; + } catch (e) { + appLogger.e('Failed to add to playlist', error: e); + return false; + } + } + + /// Remove an item from a playlist + /// [playlistId] - The playlist to remove from + /// [playlistItemId] - The playlist item ID to remove (from the item's playlistItemID field) + Future removeFromPlaylist({ + required String playlistId, + required String playlistItemId, + }) async { + try { + await _dio.delete('/playlists/$playlistId/items/$playlistItemId'); + return true; + } catch (e) { + appLogger.e('Failed to remove from playlist: $e'); + return false; + } + } + + /// Move a playlist item to a new position + /// Only works with non-smart playlists + /// [playlistId] - The playlist rating key + /// [playlistItemId] - The playlist item ID to move + /// [afterPlaylistItemId] - Move the item after this playlist item ID (0 = move to top) + Future movePlaylistItem({ + required String playlistId, + required int playlistItemId, + required int afterPlaylistItemId, + }) async { + try { + appLogger.d( + 'Moving playlist item $playlistItemId after $afterPlaylistItemId in playlist $playlistId', + ); + await _dio.put( + '/playlists/$playlistId/items/$playlistItemId/move', + queryParameters: {'after': afterPlaylistItemId}, + ); + appLogger.d('Successfully moved playlist item'); + return true; + } catch (e) { + appLogger.e('Failed to move playlist item', error: e); + return false; + } + } + + /// Clear all items from a playlist + Future clearPlaylist(String playlistId) async { + try { + await _dio.delete('/playlists/$playlistId/items'); + return true; + } catch (e) { + appLogger.e('Failed to clear playlist: $e'); + return false; + } + } + + /// Update playlist metadata (e.g., title, summary) + /// Uses the same metadata editing mechanism as other items + Future updatePlaylist({ + required String playlistId, + String? title, + String? summary, + }) async { + try { + final queryParams = { + 'type': 'playlist', + 'id': playlistId, + }; + + if (title != null) { + queryParams['title.value'] = title; + queryParams['title.locked'] = '1'; + } + if (summary != null) { + queryParams['summary.value'] = summary; + queryParams['summary.locked'] = '1'; + } + + await _dio.put( + '/library/metadata/$playlistId', + queryParameters: queryParams, + ); + return true; + } catch (e) { + appLogger.e('Failed to update playlist: $e'); + return false; + } + } + // ============================================================================ // Library Management Methods // ============================================================================ diff --git a/lib/config/plex_config.dart b/lib/config/plex_config.dart index 08675587..5d53b1a9 100644 --- a/lib/config/plex_config.dart +++ b/lib/config/plex_config.dart @@ -9,6 +9,7 @@ class PlexConfig { final String platform; final String? device; final bool acceptJson; + final String? machineIdentifier; PlexConfig({ required this.baseUrl, @@ -19,6 +20,7 @@ class PlexConfig { this.platform = 'Flutter', this.device, this.acceptJson = true, + this.machineIdentifier, }); static Future create({ @@ -29,6 +31,7 @@ class PlexConfig { String? platform, String? device, bool acceptJson = true, + String? machineIdentifier, }) async { final packageInfo = await PackageInfo.fromPlatform(); return PlexConfig( @@ -40,6 +43,7 @@ class PlexConfig { platform: platform ?? 'Flutter', device: device, acceptJson: acceptJson, + machineIdentifier: machineIdentifier, ); } @@ -70,6 +74,7 @@ class PlexConfig { String? platform, String? device, bool? acceptJson, + String? machineIdentifier, }) { return PlexConfig( baseUrl: baseUrl ?? this.baseUrl, @@ -80,6 +85,7 @@ class PlexConfig { platform: platform ?? this.platform, device: device ?? this.device, acceptJson: acceptJson ?? this.acceptJson, + machineIdentifier: machineIdentifier ?? this.machineIdentifier, ); } } diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 61e360cf..dbf4b3d0 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 6 -/// Strings: 1896 (316 per locale) +/// Strings: 2100 (350 per locale) /// -/// Built on 2025-11-15 at 00:12 UTC +/// Built on 2025-11-15 at 02:45 UTC // coverage:ignore-file // ignore_for_file: type=lint @@ -179,6 +179,7 @@ class Translations implements BaseTranslations { late final _StringsLogsEn logs = _StringsLogsEn._(_root); late final _StringsLicensesEn licenses = _StringsLicensesEn._(_root); late final _StringsNavigationEn navigation = _StringsNavigationEn._(_root); + late final _StringsPlaylistsEn playlists = _StringsPlaylistsEn._(_root); } // Path: app @@ -740,6 +741,49 @@ class _StringsNavigationEn { String get settings => 'Settings'; } +// Path: playlists +class _StringsPlaylistsEn { + _StringsPlaylistsEn._(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + String get title => 'Playlists'; + String get noPlaylists => 'No playlists found'; + String get create => 'Create Playlist'; + String get newPlaylist => 'New Playlist'; + String get playlistName => 'Playlist Name'; + String get enterPlaylistName => 'Enter playlist name'; + String get edit => 'Edit Playlist'; + String get delete => 'Delete Playlist'; + String get addTo => 'Add to Playlist'; + String get addItems => 'Add Items'; + String get removeItem => 'Remove from Playlist'; + String get clearPlaylist => 'Clear Playlist'; + String get playAll => 'Play All'; + String get shuffle => 'Shuffle'; + String get smartPlaylist => 'Smart Playlist'; + String get regularPlaylist => 'Regular Playlist'; + String itemCount({required Object count}) => '${count} items'; + String get oneItem => '1 item'; + String get emptyPlaylist => 'This playlist is empty'; + String get deleteConfirm => 'Delete Playlist?'; + String deleteMessage({required Object name}) => 'Are you sure you want to delete "${name}"?'; + String get created => 'Playlist created'; + String get updated => 'Playlist updated'; + String get deleted => 'Playlist deleted'; + String get itemAdded => 'Added to playlist'; + String get itemRemoved => 'Removed from playlist'; + String get selectPlaylist => 'Select Playlist'; + String get createNewPlaylist => 'Create New Playlist'; + String get errorCreating => 'Failed to create playlist'; + String get errorDeleting => 'Failed to delete playlist'; + String get errorLoading => 'Failed to load playlists'; + String get errorAdding => 'Failed to add to playlist'; + String get errorReordering => 'Failed to reorder playlist item'; + String get errorRemoving => 'Failed to remove from playlist'; +} + // Path: class _StringsDe implements Translations { /// You can call this constructor and build your own translation instance of this locale. @@ -791,6 +835,7 @@ class _StringsDe implements Translations { @override late final _StringsLogsDe logs = _StringsLogsDe._(_root); @override late final _StringsLicensesDe licenses = _StringsLicensesDe._(_root); @override late final _StringsNavigationDe navigation = _StringsNavigationDe._(_root); + @override late final _StringsPlaylistsDe playlists = _StringsPlaylistsDe._(_root); } // Path: app @@ -1352,6 +1397,49 @@ class _StringsNavigationDe implements _StringsNavigationEn { @override String get settings => 'Einstellungen'; } +// Path: playlists +class _StringsPlaylistsDe implements _StringsPlaylistsEn { + _StringsPlaylistsDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get title => 'Playlists'; + @override String get noPlaylists => 'Keine Playlists gefunden'; + @override String get create => 'Playlist erstellen'; + @override String get newPlaylist => 'Neue Playlist'; + @override String get playlistName => 'Playlist-Name'; + @override String get enterPlaylistName => 'Playlist-Namen eingeben'; + @override String get edit => 'Playlist bearbeiten'; + @override String get delete => 'Playlist löschen'; + @override String get addTo => 'Zur Playlist hinzufügen'; + @override String get addItems => 'Elemente hinzufügen'; + @override String get removeItem => 'Aus Playlist entfernen'; + @override String get clearPlaylist => 'Playlist leeren'; + @override String get playAll => 'Alle abspielen'; + @override String get shuffle => 'Zufällig'; + @override String get smartPlaylist => 'Intelligente Playlist'; + @override String get regularPlaylist => 'Normale Playlist'; + @override String itemCount({required Object count}) => '${count} Elemente'; + @override String get oneItem => '1 Element'; + @override String get emptyPlaylist => 'Diese Playlist ist leer'; + @override String get deleteConfirm => 'Playlist löschen?'; + @override String deleteMessage({required Object name}) => 'Möchten Sie "${name}" wirklich löschen?'; + @override String get created => 'Playlist erstellt'; + @override String get updated => 'Playlist aktualisiert'; + @override String get deleted => 'Playlist gelöscht'; + @override String get itemAdded => 'Zur Playlist hinzugefügt'; + @override String get itemRemoved => 'Aus Playlist entfernt'; + @override String get selectPlaylist => 'Playlist auswählen'; + @override String get createNewPlaylist => 'Neue Playlist erstellen'; + @override String get errorCreating => 'Fehler beim Erstellen der Playlist'; + @override String get errorDeleting => 'Fehler beim Löschen der Playlist'; + @override String get errorLoading => 'Fehler beim Laden der Playlists'; + @override String get errorAdding => 'Fehler beim Hinzufügen zur Playlist'; + @override String get errorReordering => 'Fehler beim Neuordnen des Playlist-Elements'; + @override String get errorRemoving => 'Fehler beim Entfernen aus der Playlist'; +} + // Path: class _StringsIt implements Translations { /// You can call this constructor and build your own translation instance of this locale. @@ -1403,6 +1491,7 @@ class _StringsIt implements Translations { @override late final _StringsLogsIt logs = _StringsLogsIt._(_root); @override late final _StringsLicensesIt licenses = _StringsLicensesIt._(_root); @override late final _StringsNavigationIt navigation = _StringsNavigationIt._(_root); + @override late final _StringsPlaylistsIt playlists = _StringsPlaylistsIt._(_root); } // Path: app @@ -1964,6 +2053,49 @@ class _StringsNavigationIt implements _StringsNavigationEn { @override String get settings => 'Impostazioni'; } +// Path: playlists +class _StringsPlaylistsIt implements _StringsPlaylistsEn { + _StringsPlaylistsIt._(this._root); + + @override final _StringsIt _root; // ignore: unused_field + + // Translations + @override String get title => 'Playlist'; + @override String get noPlaylists => 'Nessuna playlist trovata'; + @override String get create => 'Crea playlist'; + @override String get newPlaylist => 'Nuova playlist'; + @override String get playlistName => 'Nome playlist'; + @override String get enterPlaylistName => 'Inserisci nome playlist'; + @override String get edit => 'Modifica playlist'; + @override String get delete => 'Elimina playlist'; + @override String get addTo => 'Aggiungi a playlist'; + @override String get addItems => 'Aggiungi elementi'; + @override String get removeItem => 'Rimuovi da playlist'; + @override String get clearPlaylist => 'Svuota playlist'; + @override String get playAll => 'Riproduci tutto'; + @override String get shuffle => 'Casuale'; + @override String get smartPlaylist => 'Playlist intelligente'; + @override String get regularPlaylist => 'Playlist normale'; + @override String itemCount({required Object count}) => '${count} elementi'; + @override String get oneItem => '1 elemento'; + @override String get emptyPlaylist => 'Questa playlist è vuota'; + @override String get deleteConfirm => 'Eliminare playlist?'; + @override String deleteMessage({required Object name}) => 'Sei sicuro di voler eliminare "${name}"?'; + @override String get created => 'Playlist creata'; + @override String get updated => 'Playlist aggiornata'; + @override String get deleted => 'Playlist eliminata'; + @override String get itemAdded => 'Aggiunto alla playlist'; + @override String get itemRemoved => 'Rimosso dalla playlist'; + @override String get selectPlaylist => 'Seleziona playlist'; + @override String get createNewPlaylist => 'Crea nuova playlist'; + @override String get errorCreating => 'Errore durante la creazione della playlist'; + @override String get errorDeleting => 'Errore durante l\'eliminazione della playlist'; + @override String get errorLoading => 'Errore durante il caricamento delle playlist'; + @override String get errorAdding => 'Errore durante l\'aggiunta alla playlist'; + @override String get errorReordering => 'Errore durante il riordino dell\'elemento della playlist'; + @override String get errorRemoving => 'Errore durante la rimozione dalla playlist'; +} + // Path: class _StringsNl implements Translations { /// You can call this constructor and build your own translation instance of this locale. @@ -2015,6 +2147,7 @@ class _StringsNl implements Translations { @override late final _StringsLogsNl logs = _StringsLogsNl._(_root); @override late final _StringsLicensesNl licenses = _StringsLicensesNl._(_root); @override late final _StringsNavigationNl navigation = _StringsNavigationNl._(_root); + @override late final _StringsPlaylistsNl playlists = _StringsPlaylistsNl._(_root); } // Path: app @@ -2576,6 +2709,49 @@ class _StringsNavigationNl implements _StringsNavigationEn { @override String get settings => 'Instellingen'; } +// Path: playlists +class _StringsPlaylistsNl implements _StringsPlaylistsEn { + _StringsPlaylistsNl._(this._root); + + @override final _StringsNl _root; // ignore: unused_field + + // Translations + @override String get title => 'Afspeellijsten'; + @override String get noPlaylists => 'Geen afspeellijsten gevonden'; + @override String get create => 'Afspeellijst maken'; + @override String get newPlaylist => 'Nieuwe afspeellijst'; + @override String get playlistName => 'Naam afspeellijst'; + @override String get enterPlaylistName => 'Voer naam afspeellijst in'; + @override String get edit => 'Afspeellijst bewerken'; + @override String get delete => 'Afspeellijst verwijderen'; + @override String get addTo => 'Toevoegen aan afspeellijst'; + @override String get addItems => 'Items toevoegen'; + @override String get removeItem => 'Verwijderen uit afspeellijst'; + @override String get clearPlaylist => 'Afspeellijst wissen'; + @override String get playAll => 'Alles afspelen'; + @override String get shuffle => 'Shuffle'; + @override String get smartPlaylist => 'Slimme afspeellijst'; + @override String get regularPlaylist => 'Normale afspeellijst'; + @override String itemCount({required Object count}) => '${count} items'; + @override String get oneItem => '1 item'; + @override String get emptyPlaylist => 'Deze afspeellijst is leeg'; + @override String get deleteConfirm => 'Afspeellijst verwijderen?'; + @override String deleteMessage({required Object name}) => 'Weet je zeker dat je "${name}" wilt verwijderen?'; + @override String get created => 'Afspeellijst gemaakt'; + @override String get updated => 'Afspeellijst bijgewerkt'; + @override String get deleted => 'Afspeellijst verwijderd'; + @override String get itemAdded => 'Toegevoegd aan afspeellijst'; + @override String get itemRemoved => 'Verwijderd uit afspeellijst'; + @override String get selectPlaylist => 'Selecteer afspeellijst'; + @override String get createNewPlaylist => 'Nieuwe afspeellijst maken'; + @override String get errorCreating => 'Fout bij maken afspeellijst'; + @override String get errorDeleting => 'Fout bij verwijderen afspeellijst'; + @override String get errorLoading => 'Fout bij laden afspeellijsten'; + @override String get errorAdding => 'Fout bij toevoegen aan afspeellijst'; + @override String get errorReordering => 'Fout bij herschikken van afspeellijstitem'; + @override String get errorRemoving => 'Fout bij verwijderen uit afspeellijst'; +} + // Path: class _StringsSv implements Translations { /// You can call this constructor and build your own translation instance of this locale. @@ -2627,6 +2803,7 @@ class _StringsSv implements Translations { @override late final _StringsLogsSv logs = _StringsLogsSv._(_root); @override late final _StringsLicensesSv licenses = _StringsLicensesSv._(_root); @override late final _StringsNavigationSv navigation = _StringsNavigationSv._(_root); + @override late final _StringsPlaylistsSv playlists = _StringsPlaylistsSv._(_root); } // Path: app @@ -3188,6 +3365,49 @@ class _StringsNavigationSv implements _StringsNavigationEn { @override String get settings => 'Inställningar'; } +// Path: playlists +class _StringsPlaylistsSv implements _StringsPlaylistsEn { + _StringsPlaylistsSv._(this._root); + + @override final _StringsSv _root; // ignore: unused_field + + // Translations + @override String get title => 'Spellistor'; + @override String get noPlaylists => 'Inga spellistor hittades'; + @override String get create => 'Skapa spellista'; + @override String get newPlaylist => 'Ny spellista'; + @override String get playlistName => 'Spellistans namn'; + @override String get enterPlaylistName => 'Ange spellistans namn'; + @override String get edit => 'Redigera spellista'; + @override String get delete => 'Ta bort spellista'; + @override String get addTo => 'Lägg till i spellista'; + @override String get addItems => 'Lägg till objekt'; + @override String get removeItem => 'Ta bort från spellista'; + @override String get clearPlaylist => 'Rensa spellista'; + @override String get playAll => 'Spela alla'; + @override String get shuffle => 'Blanda'; + @override String get smartPlaylist => 'Smart spellista'; + @override String get regularPlaylist => 'Vanlig spellista'; + @override String itemCount({required Object count}) => '${count} objekt'; + @override String get oneItem => '1 objekt'; + @override String get emptyPlaylist => 'Denna spellista är tom'; + @override String get deleteConfirm => 'Ta bort spellista?'; + @override String deleteMessage({required Object name}) => 'Är du säker på att du vill ta bort "${name}"?'; + @override String get created => 'Spellista skapad'; + @override String get updated => 'Spellista uppdaterad'; + @override String get deleted => 'Spellista borttagen'; + @override String get itemAdded => 'Tillagd i spellista'; + @override String get itemRemoved => 'Borttagen från spellista'; + @override String get selectPlaylist => 'Välj spellista'; + @override String get createNewPlaylist => 'Skapa ny spellista'; + @override String get errorCreating => 'Det gick inte att skapa spellista'; + @override String get errorDeleting => 'Det gick inte att ta bort spellista'; + @override String get errorLoading => 'Det gick inte att ladda spellistor'; + @override String get errorAdding => 'Det gick inte att lägga till i spellista'; + @override String get errorReordering => 'Det gick inte att omordna spellisteobjekt'; + @override String get errorRemoving => 'Det gick inte att ta bort från spellista'; +} + // Path: class _StringsZh implements Translations { /// You can call this constructor and build your own translation instance of this locale. @@ -3239,6 +3459,7 @@ class _StringsZh implements Translations { @override late final _StringsLogsZh logs = _StringsLogsZh._(_root); @override late final _StringsLicensesZh licenses = _StringsLicensesZh._(_root); @override late final _StringsNavigationZh navigation = _StringsNavigationZh._(_root); + @override late final _StringsPlaylistsZh playlists = _StringsPlaylistsZh._(_root); } // Path: app @@ -3800,6 +4021,49 @@ class _StringsNavigationZh implements _StringsNavigationEn { @override String get settings => '设置'; } +// Path: playlists +class _StringsPlaylistsZh implements _StringsPlaylistsEn { + _StringsPlaylistsZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get title => '播放列表'; + @override String get noPlaylists => '未找到播放列表'; + @override String get create => '创建播放列表'; + @override String get newPlaylist => '新播放列表'; + @override String get playlistName => '播放列表名称'; + @override String get enterPlaylistName => '输入播放列表名称'; + @override String get edit => '编辑播放列表'; + @override String get delete => '删除播放列表'; + @override String get addTo => '添加到播放列表'; + @override String get addItems => '添加项目'; + @override String get removeItem => '从播放列表中移除'; + @override String get clearPlaylist => '清空播放列表'; + @override String get playAll => '全部播放'; + @override String get shuffle => '随机播放'; + @override String get smartPlaylist => '智能播放列表'; + @override String get regularPlaylist => '普通播放列表'; + @override String itemCount({required Object count}) => '${count} 个项目'; + @override String get oneItem => '1 个项目'; + @override String get emptyPlaylist => '此播放列表为空'; + @override String get deleteConfirm => '删除播放列表?'; + @override String deleteMessage({required Object name}) => '确定要删除 "${name}" 吗?'; + @override String get created => '播放列表已创建'; + @override String get updated => '播放列表已更新'; + @override String get deleted => '播放列表已删除'; + @override String get itemAdded => '已添加到播放列表'; + @override String get itemRemoved => '已从播放列表中移除'; + @override String get selectPlaylist => '选择播放列表'; + @override String get createNewPlaylist => '创建新播放列表'; + @override String get errorCreating => '创建播放列表失败'; + @override String get errorDeleting => '删除播放列表失败'; + @override String get errorLoading => '加载播放列表失败'; + @override String get errorAdding => '添加到播放列表失败'; + @override String get errorReordering => '重新排序播放列表项目失败'; + @override String get errorRemoving => '从播放列表中移除失败'; +} + /// Flat map(s) containing all translations. /// Only for edge cases! For simple maps, use the map function of this library. @@ -4122,6 +4386,40 @@ extension on Translations { case 'navigation.search': return 'Search'; case 'navigation.libraries': return 'Libraries'; case 'navigation.settings': return 'Settings'; + case 'playlists.title': return 'Playlists'; + case 'playlists.noPlaylists': return 'No playlists found'; + case 'playlists.create': return 'Create Playlist'; + case 'playlists.newPlaylist': return 'New Playlist'; + case 'playlists.playlistName': return 'Playlist Name'; + case 'playlists.enterPlaylistName': return 'Enter playlist name'; + case 'playlists.edit': return 'Edit Playlist'; + case 'playlists.delete': return 'Delete Playlist'; + case 'playlists.addTo': return 'Add to Playlist'; + case 'playlists.addItems': return 'Add Items'; + case 'playlists.removeItem': return 'Remove from Playlist'; + case 'playlists.clearPlaylist': return 'Clear Playlist'; + case 'playlists.playAll': return 'Play All'; + case 'playlists.shuffle': return 'Shuffle'; + case 'playlists.smartPlaylist': return 'Smart Playlist'; + case 'playlists.regularPlaylist': return 'Regular Playlist'; + case 'playlists.itemCount': return ({required Object count}) => '${count} items'; + case 'playlists.oneItem': return '1 item'; + case 'playlists.emptyPlaylist': return 'This playlist is empty'; + case 'playlists.deleteConfirm': return 'Delete Playlist?'; + case 'playlists.deleteMessage': return ({required Object name}) => 'Are you sure you want to delete "${name}"?'; + case 'playlists.created': return 'Playlist created'; + case 'playlists.updated': return 'Playlist updated'; + case 'playlists.deleted': return 'Playlist deleted'; + case 'playlists.itemAdded': return 'Added to playlist'; + case 'playlists.itemRemoved': return 'Removed from playlist'; + case 'playlists.selectPlaylist': return 'Select Playlist'; + case 'playlists.createNewPlaylist': return 'Create New Playlist'; + case 'playlists.errorCreating': return 'Failed to create playlist'; + case 'playlists.errorDeleting': return 'Failed to delete playlist'; + case 'playlists.errorLoading': return 'Failed to load playlists'; + case 'playlists.errorAdding': return 'Failed to add to playlist'; + case 'playlists.errorReordering': return 'Failed to reorder playlist item'; + case 'playlists.errorRemoving': return 'Failed to remove from playlist'; default: return null; } } @@ -4446,6 +4744,40 @@ extension on _StringsDe { case 'navigation.search': return 'Suche'; case 'navigation.libraries': return 'Bibliotheken'; case 'navigation.settings': return 'Einstellungen'; + case 'playlists.title': return 'Playlists'; + case 'playlists.noPlaylists': return 'Keine Playlists gefunden'; + case 'playlists.create': return 'Playlist erstellen'; + case 'playlists.newPlaylist': return 'Neue Playlist'; + case 'playlists.playlistName': return 'Playlist-Name'; + case 'playlists.enterPlaylistName': return 'Playlist-Namen eingeben'; + case 'playlists.edit': return 'Playlist bearbeiten'; + case 'playlists.delete': return 'Playlist löschen'; + case 'playlists.addTo': return 'Zur Playlist hinzufügen'; + case 'playlists.addItems': return 'Elemente hinzufügen'; + case 'playlists.removeItem': return 'Aus Playlist entfernen'; + case 'playlists.clearPlaylist': return 'Playlist leeren'; + case 'playlists.playAll': return 'Alle abspielen'; + case 'playlists.shuffle': return 'Zufällig'; + case 'playlists.smartPlaylist': return 'Intelligente Playlist'; + case 'playlists.regularPlaylist': return 'Normale Playlist'; + case 'playlists.itemCount': return ({required Object count}) => '${count} Elemente'; + case 'playlists.oneItem': return '1 Element'; + case 'playlists.emptyPlaylist': return 'Diese Playlist ist leer'; + case 'playlists.deleteConfirm': return 'Playlist löschen?'; + case 'playlists.deleteMessage': return ({required Object name}) => 'Möchten Sie "${name}" wirklich löschen?'; + case 'playlists.created': return 'Playlist erstellt'; + case 'playlists.updated': return 'Playlist aktualisiert'; + case 'playlists.deleted': return 'Playlist gelöscht'; + case 'playlists.itemAdded': return 'Zur Playlist hinzugefügt'; + case 'playlists.itemRemoved': return 'Aus Playlist entfernt'; + case 'playlists.selectPlaylist': return 'Playlist auswählen'; + case 'playlists.createNewPlaylist': return 'Neue Playlist erstellen'; + case 'playlists.errorCreating': return 'Fehler beim Erstellen der Playlist'; + case 'playlists.errorDeleting': return 'Fehler beim Löschen der Playlist'; + case 'playlists.errorLoading': return 'Fehler beim Laden der Playlists'; + case 'playlists.errorAdding': return 'Fehler beim Hinzufügen zur Playlist'; + case 'playlists.errorReordering': return 'Fehler beim Neuordnen des Playlist-Elements'; + case 'playlists.errorRemoving': return 'Fehler beim Entfernen aus der Playlist'; default: return null; } } @@ -4770,6 +5102,40 @@ extension on _StringsIt { case 'navigation.search': return 'Cerca'; case 'navigation.libraries': return 'Librerie'; case 'navigation.settings': return 'Impostazioni'; + case 'playlists.title': return 'Playlist'; + case 'playlists.noPlaylists': return 'Nessuna playlist trovata'; + case 'playlists.create': return 'Crea playlist'; + case 'playlists.newPlaylist': return 'Nuova playlist'; + case 'playlists.playlistName': return 'Nome playlist'; + case 'playlists.enterPlaylistName': return 'Inserisci nome playlist'; + case 'playlists.edit': return 'Modifica playlist'; + case 'playlists.delete': return 'Elimina playlist'; + case 'playlists.addTo': return 'Aggiungi a playlist'; + case 'playlists.addItems': return 'Aggiungi elementi'; + case 'playlists.removeItem': return 'Rimuovi da playlist'; + case 'playlists.clearPlaylist': return 'Svuota playlist'; + case 'playlists.playAll': return 'Riproduci tutto'; + case 'playlists.shuffle': return 'Casuale'; + case 'playlists.smartPlaylist': return 'Playlist intelligente'; + case 'playlists.regularPlaylist': return 'Playlist normale'; + case 'playlists.itemCount': return ({required Object count}) => '${count} elementi'; + case 'playlists.oneItem': return '1 elemento'; + case 'playlists.emptyPlaylist': return 'Questa playlist è vuota'; + case 'playlists.deleteConfirm': return 'Eliminare playlist?'; + case 'playlists.deleteMessage': return ({required Object name}) => 'Sei sicuro di voler eliminare "${name}"?'; + case 'playlists.created': return 'Playlist creata'; + case 'playlists.updated': return 'Playlist aggiornata'; + case 'playlists.deleted': return 'Playlist eliminata'; + case 'playlists.itemAdded': return 'Aggiunto alla playlist'; + case 'playlists.itemRemoved': return 'Rimosso dalla playlist'; + case 'playlists.selectPlaylist': return 'Seleziona playlist'; + case 'playlists.createNewPlaylist': return 'Crea nuova playlist'; + case 'playlists.errorCreating': return 'Errore durante la creazione della playlist'; + case 'playlists.errorDeleting': return 'Errore durante l\'eliminazione della playlist'; + case 'playlists.errorLoading': return 'Errore durante il caricamento delle playlist'; + case 'playlists.errorAdding': return 'Errore durante l\'aggiunta alla playlist'; + case 'playlists.errorReordering': return 'Errore durante il riordino dell\'elemento della playlist'; + case 'playlists.errorRemoving': return 'Errore durante la rimozione dalla playlist'; default: return null; } } @@ -5094,6 +5460,40 @@ extension on _StringsNl { case 'navigation.search': return 'Zoeken'; case 'navigation.libraries': return 'Bibliotheken'; case 'navigation.settings': return 'Instellingen'; + case 'playlists.title': return 'Afspeellijsten'; + case 'playlists.noPlaylists': return 'Geen afspeellijsten gevonden'; + case 'playlists.create': return 'Afspeellijst maken'; + case 'playlists.newPlaylist': return 'Nieuwe afspeellijst'; + case 'playlists.playlistName': return 'Naam afspeellijst'; + case 'playlists.enterPlaylistName': return 'Voer naam afspeellijst in'; + case 'playlists.edit': return 'Afspeellijst bewerken'; + case 'playlists.delete': return 'Afspeellijst verwijderen'; + case 'playlists.addTo': return 'Toevoegen aan afspeellijst'; + case 'playlists.addItems': return 'Items toevoegen'; + case 'playlists.removeItem': return 'Verwijderen uit afspeellijst'; + case 'playlists.clearPlaylist': return 'Afspeellijst wissen'; + case 'playlists.playAll': return 'Alles afspelen'; + case 'playlists.shuffle': return 'Shuffle'; + case 'playlists.smartPlaylist': return 'Slimme afspeellijst'; + case 'playlists.regularPlaylist': return 'Normale afspeellijst'; + case 'playlists.itemCount': return ({required Object count}) => '${count} items'; + case 'playlists.oneItem': return '1 item'; + case 'playlists.emptyPlaylist': return 'Deze afspeellijst is leeg'; + case 'playlists.deleteConfirm': return 'Afspeellijst verwijderen?'; + case 'playlists.deleteMessage': return ({required Object name}) => 'Weet je zeker dat je "${name}" wilt verwijderen?'; + case 'playlists.created': return 'Afspeellijst gemaakt'; + case 'playlists.updated': return 'Afspeellijst bijgewerkt'; + case 'playlists.deleted': return 'Afspeellijst verwijderd'; + case 'playlists.itemAdded': return 'Toegevoegd aan afspeellijst'; + case 'playlists.itemRemoved': return 'Verwijderd uit afspeellijst'; + case 'playlists.selectPlaylist': return 'Selecteer afspeellijst'; + case 'playlists.createNewPlaylist': return 'Nieuwe afspeellijst maken'; + case 'playlists.errorCreating': return 'Fout bij maken afspeellijst'; + case 'playlists.errorDeleting': return 'Fout bij verwijderen afspeellijst'; + case 'playlists.errorLoading': return 'Fout bij laden afspeellijsten'; + case 'playlists.errorAdding': return 'Fout bij toevoegen aan afspeellijst'; + case 'playlists.errorReordering': return 'Fout bij herschikken van afspeellijstitem'; + case 'playlists.errorRemoving': return 'Fout bij verwijderen uit afspeellijst'; default: return null; } } @@ -5418,6 +5818,40 @@ extension on _StringsSv { case 'navigation.search': return 'Sök'; case 'navigation.libraries': return 'Bibliotek'; case 'navigation.settings': return 'Inställningar'; + case 'playlists.title': return 'Spellistor'; + case 'playlists.noPlaylists': return 'Inga spellistor hittades'; + case 'playlists.create': return 'Skapa spellista'; + case 'playlists.newPlaylist': return 'Ny spellista'; + case 'playlists.playlistName': return 'Spellistans namn'; + case 'playlists.enterPlaylistName': return 'Ange spellistans namn'; + case 'playlists.edit': return 'Redigera spellista'; + case 'playlists.delete': return 'Ta bort spellista'; + case 'playlists.addTo': return 'Lägg till i spellista'; + case 'playlists.addItems': return 'Lägg till objekt'; + case 'playlists.removeItem': return 'Ta bort från spellista'; + case 'playlists.clearPlaylist': return 'Rensa spellista'; + case 'playlists.playAll': return 'Spela alla'; + case 'playlists.shuffle': return 'Blanda'; + case 'playlists.smartPlaylist': return 'Smart spellista'; + case 'playlists.regularPlaylist': return 'Vanlig spellista'; + case 'playlists.itemCount': return ({required Object count}) => '${count} objekt'; + case 'playlists.oneItem': return '1 objekt'; + case 'playlists.emptyPlaylist': return 'Denna spellista är tom'; + case 'playlists.deleteConfirm': return 'Ta bort spellista?'; + case 'playlists.deleteMessage': return ({required Object name}) => 'Är du säker på att du vill ta bort "${name}"?'; + case 'playlists.created': return 'Spellista skapad'; + case 'playlists.updated': return 'Spellista uppdaterad'; + case 'playlists.deleted': return 'Spellista borttagen'; + case 'playlists.itemAdded': return 'Tillagd i spellista'; + case 'playlists.itemRemoved': return 'Borttagen från spellista'; + case 'playlists.selectPlaylist': return 'Välj spellista'; + case 'playlists.createNewPlaylist': return 'Skapa ny spellista'; + case 'playlists.errorCreating': return 'Det gick inte att skapa spellista'; + case 'playlists.errorDeleting': return 'Det gick inte att ta bort spellista'; + case 'playlists.errorLoading': return 'Det gick inte att ladda spellistor'; + case 'playlists.errorAdding': return 'Det gick inte att lägga till i spellista'; + case 'playlists.errorReordering': return 'Det gick inte att omordna spellisteobjekt'; + case 'playlists.errorRemoving': return 'Det gick inte att ta bort från spellista'; default: return null; } } @@ -5742,6 +6176,40 @@ extension on _StringsZh { case 'navigation.search': return '搜索'; case 'navigation.libraries': return '媒体库'; case 'navigation.settings': return '设置'; + case 'playlists.title': return '播放列表'; + case 'playlists.noPlaylists': return '未找到播放列表'; + case 'playlists.create': return '创建播放列表'; + case 'playlists.newPlaylist': return '新播放列表'; + case 'playlists.playlistName': return '播放列表名称'; + case 'playlists.enterPlaylistName': return '输入播放列表名称'; + case 'playlists.edit': return '编辑播放列表'; + case 'playlists.delete': return '删除播放列表'; + case 'playlists.addTo': return '添加到播放列表'; + case 'playlists.addItems': return '添加项目'; + case 'playlists.removeItem': return '从播放列表中移除'; + case 'playlists.clearPlaylist': return '清空播放列表'; + case 'playlists.playAll': return '全部播放'; + case 'playlists.shuffle': return '随机播放'; + case 'playlists.smartPlaylist': return '智能播放列表'; + case 'playlists.regularPlaylist': return '普通播放列表'; + case 'playlists.itemCount': return ({required Object count}) => '${count} 个项目'; + case 'playlists.oneItem': return '1 个项目'; + case 'playlists.emptyPlaylist': return '此播放列表为空'; + case 'playlists.deleteConfirm': return '删除播放列表?'; + case 'playlists.deleteMessage': return ({required Object name}) => '确定要删除 "${name}" 吗?'; + case 'playlists.created': return '播放列表已创建'; + case 'playlists.updated': return '播放列表已更新'; + case 'playlists.deleted': return '播放列表已删除'; + case 'playlists.itemAdded': return '已添加到播放列表'; + case 'playlists.itemRemoved': return '已从播放列表中移除'; + case 'playlists.selectPlaylist': return '选择播放列表'; + case 'playlists.createNewPlaylist': return '创建新播放列表'; + case 'playlists.errorCreating': return '创建播放列表失败'; + case 'playlists.errorDeleting': return '删除播放列表失败'; + case 'playlists.errorLoading': return '加载播放列表失败'; + case 'playlists.errorAdding': return '添加到播放列表失败'; + case 'playlists.errorReordering': return '重新排序播放列表项目失败'; + case 'playlists.errorRemoving': return '从播放列表中移除失败'; default: return null; } } diff --git a/lib/i18n/strings.i18n.json b/lib/i18n/strings.i18n.json index 2d250cbd..9712e83b 100644 --- a/lib/i18n/strings.i18n.json +++ b/lib/i18n/strings.i18n.json @@ -368,5 +368,41 @@ "search": "Search", "libraries": "Libraries", "settings": "Settings" + }, + "playlists": { + "title": "Playlists", + "noPlaylists": "No playlists found", + "create": "Create Playlist", + "newPlaylist": "New Playlist", + "playlistName": "Playlist Name", + "enterPlaylistName": "Enter playlist name", + "edit": "Edit Playlist", + "delete": "Delete Playlist", + "addTo": "Add to Playlist", + "addItems": "Add Items", + "removeItem": "Remove from Playlist", + "clearPlaylist": "Clear Playlist", + "playAll": "Play All", + "shuffle": "Shuffle", + "smartPlaylist": "Smart Playlist", + "regularPlaylist": "Regular Playlist", + "itemCount": "${count} items", + "oneItem": "1 item", + "emptyPlaylist": "This playlist is empty", + "deleteConfirm": "Delete Playlist?", + "deleteMessage": "Are you sure you want to delete \"${name}\"?", + "created": "Playlist created", + "updated": "Playlist updated", + "deleted": "Playlist deleted", + "itemAdded": "Added to playlist", + "itemRemoved": "Removed from playlist", + "selectPlaylist": "Select Playlist", + "createNewPlaylist": "Create New Playlist", + "errorCreating": "Failed to create playlist", + "errorDeleting": "Failed to delete playlist", + "errorLoading": "Failed to load playlists", + "errorAdding": "Failed to add to playlist", + "errorReordering": "Failed to reorder playlist item", + "errorRemoving": "Failed to remove from playlist" } } diff --git a/lib/i18n/strings_de.i18n.json b/lib/i18n/strings_de.i18n.json index 176effea..fa5c662c 100644 --- a/lib/i18n/strings_de.i18n.json +++ b/lib/i18n/strings_de.i18n.json @@ -368,5 +368,41 @@ "search": "Suche", "libraries": "Bibliotheken", "settings": "Einstellungen" + }, + "playlists": { + "title": "Playlists", + "noPlaylists": "Keine Playlists gefunden", + "create": "Playlist erstellen", + "newPlaylist": "Neue Playlist", + "playlistName": "Playlist-Name", + "enterPlaylistName": "Playlist-Namen eingeben", + "edit": "Playlist bearbeiten", + "delete": "Playlist löschen", + "addTo": "Zur Playlist hinzufügen", + "addItems": "Elemente hinzufügen", + "removeItem": "Aus Playlist entfernen", + "clearPlaylist": "Playlist leeren", + "playAll": "Alle abspielen", + "shuffle": "Zufällig", + "smartPlaylist": "Intelligente Playlist", + "regularPlaylist": "Normale Playlist", + "itemCount": "${count} Elemente", + "oneItem": "1 Element", + "emptyPlaylist": "Diese Playlist ist leer", + "deleteConfirm": "Playlist löschen?", + "deleteMessage": "Möchten Sie \"${name}\" wirklich löschen?", + "created": "Playlist erstellt", + "updated": "Playlist aktualisiert", + "deleted": "Playlist gelöscht", + "itemAdded": "Zur Playlist hinzugefügt", + "itemRemoved": "Aus Playlist entfernt", + "selectPlaylist": "Playlist auswählen", + "createNewPlaylist": "Neue Playlist erstellen", + "errorCreating": "Fehler beim Erstellen der Playlist", + "errorDeleting": "Fehler beim Löschen der Playlist", + "errorLoading": "Fehler beim Laden der Playlists", + "errorAdding": "Fehler beim Hinzufügen zur Playlist", + "errorReordering": "Fehler beim Neuordnen des Playlist-Elements", + "errorRemoving": "Fehler beim Entfernen aus der Playlist" } } diff --git a/lib/i18n/strings_it.i18n.json b/lib/i18n/strings_it.i18n.json index b206b80d..8672f423 100644 --- a/lib/i18n/strings_it.i18n.json +++ b/lib/i18n/strings_it.i18n.json @@ -368,5 +368,41 @@ "search": "Cerca", "libraries": "Librerie", "settings": "Impostazioni" + }, + "playlists": { + "title": "Playlist", + "noPlaylists": "Nessuna playlist trovata", + "create": "Crea playlist", + "newPlaylist": "Nuova playlist", + "playlistName": "Nome playlist", + "enterPlaylistName": "Inserisci nome playlist", + "edit": "Modifica playlist", + "delete": "Elimina playlist", + "addTo": "Aggiungi a playlist", + "addItems": "Aggiungi elementi", + "removeItem": "Rimuovi da playlist", + "clearPlaylist": "Svuota playlist", + "playAll": "Riproduci tutto", + "shuffle": "Casuale", + "smartPlaylist": "Playlist intelligente", + "regularPlaylist": "Playlist normale", + "itemCount": "${count} elementi", + "oneItem": "1 elemento", + "emptyPlaylist": "Questa playlist è vuota", + "deleteConfirm": "Eliminare playlist?", + "deleteMessage": "Sei sicuro di voler eliminare \"${name}\"?", + "created": "Playlist creata", + "updated": "Playlist aggiornata", + "deleted": "Playlist eliminata", + "itemAdded": "Aggiunto alla playlist", + "itemRemoved": "Rimosso dalla playlist", + "selectPlaylist": "Seleziona playlist", + "createNewPlaylist": "Crea nuova playlist", + "errorCreating": "Errore durante la creazione della playlist", + "errorDeleting": "Errore durante l'eliminazione della playlist", + "errorLoading": "Errore durante il caricamento delle playlist", + "errorAdding": "Errore durante l'aggiunta alla playlist", + "errorReordering": "Errore durante il riordino dell'elemento della playlist", + "errorRemoving": "Errore durante la rimozione dalla playlist" } } diff --git a/lib/i18n/strings_nl.i18n.json b/lib/i18n/strings_nl.i18n.json index ec1e906f..04640b3f 100644 --- a/lib/i18n/strings_nl.i18n.json +++ b/lib/i18n/strings_nl.i18n.json @@ -368,5 +368,41 @@ "search": "Zoeken", "libraries": "Bibliotheken", "settings": "Instellingen" + }, + "playlists": { + "title": "Afspeellijsten", + "noPlaylists": "Geen afspeellijsten gevonden", + "create": "Afspeellijst maken", + "newPlaylist": "Nieuwe afspeellijst", + "playlistName": "Naam afspeellijst", + "enterPlaylistName": "Voer naam afspeellijst in", + "edit": "Afspeellijst bewerken", + "delete": "Afspeellijst verwijderen", + "addTo": "Toevoegen aan afspeellijst", + "addItems": "Items toevoegen", + "removeItem": "Verwijderen uit afspeellijst", + "clearPlaylist": "Afspeellijst wissen", + "playAll": "Alles afspelen", + "shuffle": "Shuffle", + "smartPlaylist": "Slimme afspeellijst", + "regularPlaylist": "Normale afspeellijst", + "itemCount": "${count} items", + "oneItem": "1 item", + "emptyPlaylist": "Deze afspeellijst is leeg", + "deleteConfirm": "Afspeellijst verwijderen?", + "deleteMessage": "Weet je zeker dat je \"${name}\" wilt verwijderen?", + "created": "Afspeellijst gemaakt", + "updated": "Afspeellijst bijgewerkt", + "deleted": "Afspeellijst verwijderd", + "itemAdded": "Toegevoegd aan afspeellijst", + "itemRemoved": "Verwijderd uit afspeellijst", + "selectPlaylist": "Selecteer afspeellijst", + "createNewPlaylist": "Nieuwe afspeellijst maken", + "errorCreating": "Fout bij maken afspeellijst", + "errorDeleting": "Fout bij verwijderen afspeellijst", + "errorLoading": "Fout bij laden afspeellijsten", + "errorAdding": "Fout bij toevoegen aan afspeellijst", + "errorReordering": "Fout bij herschikken van afspeellijstitem", + "errorRemoving": "Fout bij verwijderen uit afspeellijst" } } diff --git a/lib/i18n/strings_sv.i18n.json b/lib/i18n/strings_sv.i18n.json index e118824d..54f72356 100644 --- a/lib/i18n/strings_sv.i18n.json +++ b/lib/i18n/strings_sv.i18n.json @@ -368,5 +368,41 @@ "search": "Sök", "libraries": "Bibliotek", "settings": "Inställningar" + }, + "playlists": { + "title": "Spellistor", + "noPlaylists": "Inga spellistor hittades", + "create": "Skapa spellista", + "newPlaylist": "Ny spellista", + "playlistName": "Spellistans namn", + "enterPlaylistName": "Ange spellistans namn", + "edit": "Redigera spellista", + "delete": "Ta bort spellista", + "addTo": "Lägg till i spellista", + "addItems": "Lägg till objekt", + "removeItem": "Ta bort från spellista", + "clearPlaylist": "Rensa spellista", + "playAll": "Spela alla", + "shuffle": "Blanda", + "smartPlaylist": "Smart spellista", + "regularPlaylist": "Vanlig spellista", + "itemCount": "${count} objekt", + "oneItem": "1 objekt", + "emptyPlaylist": "Denna spellista är tom", + "deleteConfirm": "Ta bort spellista?", + "deleteMessage": "Är du säker på att du vill ta bort \"${name}\"?", + "created": "Spellista skapad", + "updated": "Spellista uppdaterad", + "deleted": "Spellista borttagen", + "itemAdded": "Tillagd i spellista", + "itemRemoved": "Borttagen från spellista", + "selectPlaylist": "Välj spellista", + "createNewPlaylist": "Skapa ny spellista", + "errorCreating": "Det gick inte att skapa spellista", + "errorDeleting": "Det gick inte att ta bort spellista", + "errorLoading": "Det gick inte att ladda spellistor", + "errorAdding": "Det gick inte att lägga till i spellista", + "errorReordering": "Det gick inte att omordna spellisteobjekt", + "errorRemoving": "Det gick inte att ta bort från spellista" } } diff --git a/lib/i18n/strings_zh.i18n.json b/lib/i18n/strings_zh.i18n.json index 34eae804..705ae2ee 100644 --- a/lib/i18n/strings_zh.i18n.json +++ b/lib/i18n/strings_zh.i18n.json @@ -368,5 +368,41 @@ "search": "搜索", "libraries": "媒体库", "settings": "设置" + }, + "playlists": { + "title": "播放列表", + "noPlaylists": "未找到播放列表", + "create": "创建播放列表", + "newPlaylist": "新播放列表", + "playlistName": "播放列表名称", + "enterPlaylistName": "输入播放列表名称", + "edit": "编辑播放列表", + "delete": "删除播放列表", + "addTo": "添加到播放列表", + "addItems": "添加项目", + "removeItem": "从播放列表中移除", + "clearPlaylist": "清空播放列表", + "playAll": "全部播放", + "shuffle": "随机播放", + "smartPlaylist": "智能播放列表", + "regularPlaylist": "普通播放列表", + "itemCount": "${count} 个项目", + "oneItem": "1 个项目", + "emptyPlaylist": "此播放列表为空", + "deleteConfirm": "删除播放列表?", + "deleteMessage": "确定要删除 \"${name}\" 吗?", + "created": "播放列表已创建", + "updated": "播放列表已更新", + "deleted": "播放列表已删除", + "itemAdded": "已添加到播放列表", + "itemRemoved": "已从播放列表中移除", + "selectPlaylist": "选择播放列表", + "createNewPlaylist": "创建新播放列表", + "errorCreating": "创建播放列表失败", + "errorDeleting": "删除播放列表失败", + "errorLoading": "加载播放列表失败", + "errorAdding": "添加到播放列表失败", + "errorReordering": "重新排序播放列表项目失败", + "errorRemoving": "从播放列表中移除失败" } } diff --git a/lib/models/plex_metadata.dart b/lib/models/plex_metadata.dart index 4a667cf8..52233ed1 100644 --- a/lib/models/plex_metadata.dart +++ b/lib/models/plex_metadata.dart @@ -40,6 +40,7 @@ class PlexMetadata { final List? role; // Cast members final String? audioLanguage; // Per-media preferred audio language final String? subtitleLanguage; // Per-media preferred subtitle language + final int? playlistItemID; // Playlist item ID (for dumb playlists only) // Transient field for clear logo (extracted from Image array) String? _clearLogo; @@ -79,6 +80,7 @@ class PlexMetadata { this.role, this.audioLanguage, this.subtitleLanguage, + this.playlistItemID, }); /// Create a copy of this metadata with optional field overrides @@ -116,6 +118,7 @@ class PlexMetadata { List? role, String? audioLanguage, String? subtitleLanguage, + int? playlistItemID, }) { final copy = PlexMetadata( ratingKey: ratingKey ?? this.ratingKey, @@ -151,6 +154,7 @@ class PlexMetadata { role: role ?? this.role, audioLanguage: audioLanguage ?? this.audioLanguage, subtitleLanguage: subtitleLanguage ?? this.subtitleLanguage, + playlistItemID: playlistItemID ?? this.playlistItemID, ); // Preserve clearLogo copy._clearLogo = _clearLogo; diff --git a/lib/models/plex_metadata.g.dart b/lib/models/plex_metadata.g.dart index 9d6de3a1..a9f262ea 100644 --- a/lib/models/plex_metadata.g.dart +++ b/lib/models/plex_metadata.g.dart @@ -42,6 +42,7 @@ PlexMetadata _$PlexMetadataFromJson(Map json) => PlexMetadata( .toList(), audioLanguage: json['audioLanguage'] as String?, subtitleLanguage: json['subtitleLanguage'] as String?, + playlistItemID: (json['playlistItemID'] as num?)?.toInt(), ); Map _$PlexMetadataToJson(PlexMetadata instance) => @@ -79,4 +80,5 @@ Map _$PlexMetadataToJson(PlexMetadata instance) => 'Role': instance.role, 'audioLanguage': instance.audioLanguage, 'subtitleLanguage': instance.subtitleLanguage, + 'playlistItemID': instance.playlistItemID, }; diff --git a/lib/models/plex_playlist.dart b/lib/models/plex_playlist.dart new file mode 100644 index 00000000..d47944f7 --- /dev/null +++ b/lib/models/plex_playlist.dart @@ -0,0 +1,66 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'plex_playlist.g.dart'; + +@JsonSerializable() +class PlexPlaylist { + final String ratingKey; + final String key; + final String type; // "playlist" + final String title; + final String? summary; + final bool smart; + final String playlistType; // video, audio, photo + final int? duration; + final int? leafCount; // Number of items in playlist + final String? composite; // Composite thumbnail image + final int? addedAt; + final int? updatedAt; + final int? lastViewedAt; + final int? viewCount; + final String? content; // For smart playlists - generator URI + final String? guid; + final String? thumb; + + PlexPlaylist({ + required this.ratingKey, + required this.key, + required this.type, + required this.title, + this.summary, + required this.smart, + required this.playlistType, + this.duration, + this.leafCount, + this.composite, + this.addedAt, + this.updatedAt, + this.lastViewedAt, + this.viewCount, + this.content, + this.guid, + this.thumb, + }); + + /// Helper to get display image (composite or thumb) + String? get displayImage => composite ?? thumb; + + /// Helper to get formatted duration + String? get formattedDuration { + if (duration == null) return null; + final hours = duration! ~/ 3600000; + final minutes = (duration! % 3600000) ~/ 60000; + if (hours > 0) { + return '${hours}h ${minutes}m'; + } + return '${minutes}m'; + } + + /// Helper to determine if playlist is editable + bool get isEditable => !smart; + + factory PlexPlaylist.fromJson(Map json) => + _$PlexPlaylistFromJson(json); + + Map toJson() => _$PlexPlaylistToJson(this); +} diff --git a/lib/models/plex_playlist.g.dart b/lib/models/plex_playlist.g.dart new file mode 100644 index 00000000..96647865 --- /dev/null +++ b/lib/models/plex_playlist.g.dart @@ -0,0 +1,48 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'plex_playlist.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +PlexPlaylist _$PlexPlaylistFromJson(Map json) => PlexPlaylist( + ratingKey: json['ratingKey'] as String, + key: json['key'] as String, + type: json['type'] as String, + title: json['title'] as String, + summary: json['summary'] as String?, + smart: json['smart'] as bool, + playlistType: json['playlistType'] as String, + duration: (json['duration'] as num?)?.toInt(), + leafCount: (json['leafCount'] as num?)?.toInt(), + composite: json['composite'] as String?, + addedAt: (json['addedAt'] as num?)?.toInt(), + updatedAt: (json['updatedAt'] as num?)?.toInt(), + lastViewedAt: (json['lastViewedAt'] as num?)?.toInt(), + viewCount: (json['viewCount'] as num?)?.toInt(), + content: json['content'] as String?, + guid: json['guid'] as String?, + thumb: json['thumb'] as String?, +); + +Map _$PlexPlaylistToJson(PlexPlaylist instance) => + { + 'ratingKey': instance.ratingKey, + 'key': instance.key, + 'type': instance.type, + 'title': instance.title, + 'summary': instance.summary, + 'smart': instance.smart, + 'playlistType': instance.playlistType, + 'duration': instance.duration, + 'leafCount': instance.leafCount, + 'composite': instance.composite, + 'addedAt': instance.addedAt, + 'updatedAt': instance.updatedAt, + 'lastViewedAt': instance.lastViewedAt, + 'viewCount': instance.viewCount, + 'content': instance.content, + 'guid': instance.guid, + 'thumb': instance.thumb, + }; diff --git a/lib/providers/playback_state_provider.dart b/lib/providers/playback_state_provider.dart index 721345af..c13d7c3f 100644 --- a/lib/providers/playback_state_provider.dart +++ b/lib/providers/playback_state_provider.dart @@ -1,98 +1,126 @@ import 'package:flutter/foundation.dart'; import '../models/plex_metadata.dart'; -/// Manages shuffle playback state for TV shows and seasons. +/// Playback mode types +enum PlaybackMode { + none, // No active playback queue + sequential, // Normal episode-to-episode playback (uses Plex API) + shufflePlay, // Shuffle play for shows/seasons + playlist, // Playlist playback (ordered or shuffled) +} + +/// Manages playback state for TV shows, seasons, and playlists. /// This provider is session-only and does not persist across app restarts. class PlaybackStateProvider with ChangeNotifier { - List _shuffleQueue = []; - String? - _shuffleContextKey; // The show/season ratingKey for this shuffle session + List _queue = []; + String? _contextKey; // The show/season/playlist ratingKey for this session int _currentIndex = 0; + PlaybackMode _playbackMode = PlaybackMode.none; + + /// Current playback mode + PlaybackMode get playbackMode => _playbackMode; /// Whether shuffle mode is currently active - bool get isShuffleActive => _shuffleQueue.isNotEmpty; + bool get isShuffleActive => _playbackMode == PlaybackMode.shufflePlay; - /// The context key (show or season ratingKey) for the current shuffle session - String? get shuffleContextKey => _shuffleContextKey; + /// Whether playlist mode is currently active + bool get isPlaylistActive => _playbackMode == PlaybackMode.playlist; + + /// Whether any queue-based playback is active + bool get isQueueActive => _queue.isNotEmpty && _playbackMode != PlaybackMode.none; + + /// The context key (show/season/playlist ratingKey) for the current session + String? get shuffleContextKey => _contextKey; /// Sets a new shuffle queue and starts shuffle mode void setShuffleQueue(List episodes, String contextKey) { - _shuffleQueue = List.from(episodes); - _shuffleContextKey = contextKey; + _queue = List.from(episodes); + _contextKey = contextKey; _currentIndex = 0; + _playbackMode = PlaybackMode.shufflePlay; notifyListeners(); } - /// Gets the next episode in the shuffle queue. - /// Returns null if queue is exhausted or current episode is not in queue. + /// Sets a playback queue for playlist playback (ordered, not shuffled) + void setPlaybackQueue(List items, String contextKey) { + _queue = List.from(items); + _contextKey = contextKey; + _currentIndex = 0; + _playbackMode = PlaybackMode.playlist; + notifyListeners(); + } + + /// Gets the next item in the playback queue. + /// Returns null if queue is exhausted or current item is not in queue. /// [loopQueue] - If true, restart from beginning when queue is exhausted PlexMetadata? getNextEpisode( - String currentEpisodeKey, { + String currentItemKey, { bool loopQueue = false, }) { - if (_shuffleQueue.isEmpty) return null; + if (_queue.isEmpty) return null; - // Find current episode in queue - final currentIndex = _shuffleQueue.indexWhere( - (ep) => ep.ratingKey == currentEpisodeKey, + // Find current item in queue + final currentIndex = _queue.indexWhere( + (item) => item.ratingKey == currentItemKey, ); if (currentIndex == -1) { - // Current episode not in queue, clear shuffle + // Current item not in queue, clear queue clearShuffle(); return null; } - // Check if there's a next episode - if (currentIndex + 1 >= _shuffleQueue.length) { + // Check if there's a next item + if (currentIndex + 1 >= _queue.length) { // Queue exhausted - if (loopQueue && _shuffleQueue.isNotEmpty) { + if (loopQueue && _queue.isNotEmpty) { // Loop back to beginning _currentIndex = 0; - return _shuffleQueue[_currentIndex]; + return _queue[_currentIndex]; } return null; } _currentIndex = currentIndex + 1; - return _shuffleQueue[_currentIndex]; + return _queue[_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; + /// Gets the previous item in the playback queue. + /// Returns null if at the beginning of the queue or current item is not in queue. + PlexMetadata? getPreviousEpisode(String currentItemKey) { + if (_queue.isEmpty) return null; - // Find current episode in queue - final currentIndex = _shuffleQueue.indexWhere( - (ep) => ep.ratingKey == currentEpisodeKey, + // Find current item in queue + final currentIndex = _queue.indexWhere( + (item) => item.ratingKey == currentItemKey, ); if (currentIndex == -1) { - // Current episode not in queue + // Current item not in queue return null; } - // Check if there's a previous episode + // Check if there's a previous item if (currentIndex <= 0) { // At the beginning of queue return null; } _currentIndex = currentIndex - 1; - return _shuffleQueue[_currentIndex]; + return _queue[_currentIndex]; } - /// Clears the shuffle queue and exits shuffle mode + /// Clears the playback queue and exits queue mode void clearShuffle() { - _shuffleQueue = []; - _shuffleContextKey = null; + _queue = []; + _contextKey = null; _currentIndex = 0; + _playbackMode = PlaybackMode.none; notifyListeners(); } - /// Gets the total number of episodes in the current shuffle queue - int get queueLength => _shuffleQueue.length; + /// Gets the total number of items in the current playback queue + int get queueLength => _queue.length; /// Gets the current position in the queue (1-indexed) int get currentPosition => _currentIndex + 1; diff --git a/lib/screens/libraries_screen.dart b/lib/screens/libraries_screen.dart index b7a65fd2..cf5a912e 100644 --- a/lib/screens/libraries_screen.dart +++ b/lib/screens/libraries_screen.dart @@ -21,6 +21,7 @@ import '../mixins/refreshable.dart'; import '../mixins/item_updatable.dart'; import '../theme/theme_helper.dart'; import '../i18n/strings.g.dart'; +import 'playlists_screen.dart'; class LibrariesScreen extends StatefulWidget { const LibrariesScreen({super.key}); @@ -1125,6 +1126,18 @@ class _LibrariesScreenState extends State ], ], ), + floatingActionButton: FloatingActionButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const PlaylistsScreen(), + ), + ); + }, + tooltip: t.playlists.title, + child: const Icon(Icons.playlist_play), + ), ); } diff --git a/lib/screens/playlist_detail_screen.dart b/lib/screens/playlist_detail_screen.dart new file mode 100644 index 00000000..c96af4ec --- /dev/null +++ b/lib/screens/playlist_detail_screen.dart @@ -0,0 +1,517 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../client/plex_client.dart'; +import '../models/plex_playlist.dart'; +import '../models/plex_metadata.dart'; +import '../providers/settings_provider.dart'; +import '../providers/playback_state_provider.dart'; +import '../services/settings_service.dart'; +import '../utils/provider_extensions.dart'; +import '../utils/app_logger.dart'; +import '../utils/video_player_navigation.dart'; +import '../widgets/media_card.dart'; +import '../widgets/playlist_item_card.dart'; +import '../widgets/desktop_app_bar.dart'; +import '../mixins/refreshable.dart'; +import '../mixins/item_updatable.dart'; +import '../i18n/strings.g.dart'; + +/// Screen to display the contents of a playlist +class PlaylistDetailScreen extends StatefulWidget { + final PlexPlaylist playlist; + + const PlaylistDetailScreen({super.key, required this.playlist}); + + @override + State createState() => _PlaylistDetailScreenState(); +} + +class _PlaylistDetailScreenState extends State + with Refreshable, ItemUpdatable { + @override + PlexClient get client => context.clientSafe; + + List _items = []; + bool _isLoading = false; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadPlaylistItems(); + } + + Future _loadPlaylistItems() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final clientProvider = context.plexClient; + final client = clientProvider.client; + if (client == null) { + throw Exception('No client available'); + } + + final items = await client.getPlaylist(widget.playlist.ratingKey); + + setState(() { + _items = items; + _isLoading = false; + }); + + appLogger.d('Loaded ${items.length} items for playlist: ${widget.playlist.title}'); + } catch (e) { + appLogger.e('Failed to load playlist items', error: e); + setState(() { + _errorMessage = 'Failed to load playlist items: ${e.toString()}'; + _isLoading = false; + }); + } + } + + Future _deletePlaylist() async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(t.playlists.deleteConfirm), + content: Text(t.playlists.deleteMessage(name: widget.playlist.title)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(t.common.cancel), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: Text(t.playlists.delete), + style: TextButton.styleFrom(foregroundColor: Colors.red), + ), + ], + ), + ); + + if (confirmed == true && mounted) { + final success = await client.deletePlaylist(widget.playlist.ratingKey); + + if (mounted) { + if (success) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.playlists.deleted)), + ); + Navigator.pop(context); // Return to playlists screen + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.playlists.errorDeleting)), + ); + } + } + } + } + + Future _onReorder(int oldIndex, int newIndex) async { + // Adjust newIndex if moving down in the list + if (newIndex > oldIndex) { + newIndex--; + } + + // Can't reorder if indices are the same + if (oldIndex == newIndex) return; + + final movedItem = _items[oldIndex]; + + // Check if item has playlistItemID (required for reordering) + if (movedItem.playlistItemID == null) { + appLogger.e('Cannot reorder: item missing playlistItemID'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.playlists.errorReordering)), + ); + } + return; + } + + // Determine the "after" item ID + // If moving to position 0, afterPlaylistItemId should be 0 (move to top) + // Otherwise, use the playlistItemID of the item before the new position + final int afterPlaylistItemId; + if (newIndex == 0) { + afterPlaylistItemId = 0; // Move to top + } else { + final afterItem = _items[newIndex - 1]; + if (afterItem.playlistItemID == null) { + appLogger.e('Cannot reorder: after item missing playlistItemID'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.playlists.errorReordering)), + ); + } + return; + } + afterPlaylistItemId = afterItem.playlistItemID!; + } + + appLogger.d('Reordering item from $oldIndex to $newIndex (after ID: $afterPlaylistItemId)'); + + // Optimistically update UI + setState(() { + final item = _items.removeAt(oldIndex); + _items.insert(newIndex, item); + }); + + // Call API to persist the change + final success = await client.movePlaylistItem( + playlistId: widget.playlist.ratingKey, + playlistItemId: movedItem.playlistItemID!, + afterPlaylistItemId: afterPlaylistItemId, + ); + + if (!success) { + // Revert on failure + appLogger.e('Failed to reorder playlist item, reverting UI'); + if (mounted) { + setState(() { + final item = _items.removeAt(newIndex); + _items.insert(oldIndex, item); + }); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.playlists.errorReordering)), + ); + } + } + } + + Future _removeItem(int index) async { + final item = _items[index]; + + // Check if item has playlistItemID (required for removal) + if (item.playlistItemID == null) { + appLogger.e('Cannot remove: item missing playlistItemID'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.playlists.errorRemoving)), + ); + } + return; + } + + appLogger.d('Removing item ${item.title} (playlistItemID: ${item.playlistItemID}) from playlist'); + + // Optimistically update UI + setState(() { + _items.removeAt(index); + }); + + // Call API to persist the change + final success = await client.removeFromPlaylist( + playlistId: widget.playlist.ratingKey, + playlistItemId: item.playlistItemID.toString(), + ); + + if (mounted) { + if (success) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.playlists.itemRemoved)), + ); + } else { + // Revert on failure + appLogger.e('Failed to remove playlist item, reverting UI'); + setState(() { + _items.insert(index, item); + }); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.playlists.errorRemoving)), + ); + } + } + } + + @override + void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) { + final index = _items.indexWhere((item) => item.ratingKey == ratingKey); + if (index != -1) { + _items[index] = updatedMetadata; + } + } + + @override + void refresh() { + _loadPlaylistItems(); + } + + Future _playPlaylist() async { + if (_items.isEmpty) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.playlists.emptyPlaylist)), + ); + } + return; + } + + final playbackState = context.read(); + + // Set the playlist items as the playback queue (in order, not shuffled) + playbackState.setPlaybackQueue(_items, widget.playlist.ratingKey); + + // Navigate to the first item + if (mounted) { + await navigateToVideoPlayer(context, metadata: _items.first); + } + } + + Future _shufflePlayPlaylist() async { + if (_items.isEmpty) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.playlists.emptyPlaylist)), + ); + } + return; + } + + final playbackState = context.read(); + + // Shuffle the items + final shuffledItems = List.from(_items)..shuffle(); + + // Set the shuffled playlist items as the playback queue (playlist mode, not shuffle mode) + playbackState.setPlaybackQueue(shuffledItems, widget.playlist.ratingKey); + + // Navigate to the first shuffled item + if (mounted) { + await navigateToVideoPlayer(context, metadata: shuffledItems.first); + } + } + + Future _playFromItem(int index) async { + if (_items.isEmpty || index < 0 || index >= _items.length) return; + + final playbackState = context.read(); + + // Set the full playlist as playback queue (in order) + playbackState.setPlaybackQueue(_items, widget.playlist.ratingKey); + + // Start playing from the clicked item + if (mounted) { + await navigateToVideoPlayer(context, metadata: _items[index]); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: CustomScrollView( + slivers: [ + CustomAppBar( + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.playlist.title, + style: const TextStyle(fontSize: 16), + ), + if (widget.playlist.smart) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.auto_awesome, + size: 12, + color: Colors.blue[300], + ), + const SizedBox(width: 4), + Text( + t.playlists.smartPlaylist, + style: TextStyle( + fontSize: 11, + color: Colors.blue[300], + fontWeight: FontWeight.normal, + ), + ), + ], + ), + ], + ), + pinned: true, + actions: [ + // Play button + if (_items.isNotEmpty) + IconButton( + icon: const Icon(Icons.play_arrow), + tooltip: t.discover.play, + onPressed: _playPlaylist, + ), + // Shuffle button + if (_items.isNotEmpty) + IconButton( + icon: const Icon(Icons.shuffle), + tooltip: t.playlists.shuffle, + onPressed: _shufflePlayPlaylist, + ), + // Delete button for non-smart playlists + if (!widget.playlist.smart) + IconButton( + icon: const Icon(Icons.delete), + tooltip: t.playlists.delete, + onPressed: _deletePlaylist, + color: Colors.red, + ), + ], + ), + if (_errorMessage != null) + SliverFillRemaining( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.error_outline, + size: 48, + color: Colors.red, + ), + const SizedBox(height: 16), + Text(_errorMessage!), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadPlaylistItems, + child: Text(t.common.retry), + ), + ], + ), + ), + ) + else if (_items.isEmpty && _isLoading) + const SliverFillRemaining( + child: Center(child: CircularProgressIndicator()), + ) + else if (_items.isEmpty) + SliverFillRemaining( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.playlist_play, + size: 64, + color: Colors.grey, + ), + const SizedBox(height: 16), + Text( + t.playlists.emptyPlaylist, + style: const TextStyle(fontSize: 16, color: Colors.grey), + ), + ], + ), + ), + ) + else if (widget.playlist.smart) + // Smart playlists: Use grid view (cannot be reordered) + SliverPadding( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), + sliver: SliverGrid( + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: _getMaxCrossAxisExtent( + context, + context.watch().libraryDensity, + ), + childAspectRatio: 2 / 3.3, + crossAxisSpacing: 0, + mainAxisSpacing: 0, + ), + delegate: SliverChildBuilderDelegate((context, index) { + return MediaCard( + item: _items[index], + onRefresh: updateItem, + ); + }, childCount: _items.length), + ), + ) + else + // Regular playlists: Use reorderable list view + SliverReorderableList( + itemBuilder: (context, index) { + final item = _items[index]; + return PlaylistItemCard( + key: ValueKey(item.playlistItemID ?? item.ratingKey), + item: item, + index: index, + onRemove: () => _removeItem(index), + onTap: () => _playFromItem(index), + canReorder: !widget.playlist.smart, + ); + }, + itemCount: _items.length, + onReorder: _onReorder, + ), + ], + ), + ); + } + + double _getMaxCrossAxisExtent(BuildContext context, LibraryDensity density) { + final screenWidth = MediaQuery.of(context).size.width; + final padding = 16.0; + final availableWidth = screenWidth - padding; + + if (screenWidth >= 900) { + double divisor; + double maxItemWidth; + + switch (density) { + case LibraryDensity.comfortable: + divisor = 6.5; + maxItemWidth = 280; + break; + case LibraryDensity.normal: + divisor = 8.0; + maxItemWidth = 200; + break; + case LibraryDensity.compact: + divisor = 10.0; + maxItemWidth = 160; + break; + } + + return (availableWidth / divisor).clamp(120, maxItemWidth); + } else if (screenWidth >= 600) { + double divisor; + double maxItemWidth; + + switch (density) { + case LibraryDensity.comfortable: + divisor = 4.5; + maxItemWidth = 220; + break; + case LibraryDensity.normal: + divisor = 5.5; + maxItemWidth = 180; + break; + case LibraryDensity.compact: + divisor = 7.0; + maxItemWidth = 140; + break; + } + + return (availableWidth / divisor).clamp(100, maxItemWidth); + } else { + double divisor; + + switch (density) { + case LibraryDensity.comfortable: + divisor = 2.2; + break; + case LibraryDensity.normal: + divisor = 2.8; + break; + case LibraryDensity.compact: + divisor = 3.5; + break; + } + + return availableWidth / divisor; + } + } +} diff --git a/lib/screens/playlists_screen.dart b/lib/screens/playlists_screen.dart new file mode 100644 index 00000000..82fad9f0 --- /dev/null +++ b/lib/screens/playlists_screen.dart @@ -0,0 +1,404 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../client/plex_client.dart'; +import '../models/plex_playlist.dart'; +import '../providers/settings_provider.dart'; +import '../services/settings_service.dart'; +import '../utils/provider_extensions.dart'; +import '../utils/app_logger.dart'; +import '../widgets/desktop_app_bar.dart'; +import '../mixins/refreshable.dart'; +import '../i18n/strings.g.dart'; +import 'playlist_detail_screen.dart'; + +/// Screen to display all video playlists +class PlaylistsScreen extends StatefulWidget { + const PlaylistsScreen({super.key}); + + @override + State createState() => _PlaylistsScreenState(); +} + +class _PlaylistsScreenState extends State with Refreshable { + PlexClient get client => context.clientSafe; + + List _playlists = []; + bool _isLoading = false; + String? _errorMessage; + bool? _filterSmart; + + @override + void initState() { + super.initState(); + _loadPlaylists(); + } + + Future _loadPlaylists() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final clientProvider = context.plexClient; + final client = clientProvider.client; + if (client == null) { + throw Exception('No client available'); + } + + final playlists = await client.getPlaylists( + playlistType: 'video', + smart: _filterSmart, + ); + + setState(() { + _playlists = playlists; + _isLoading = false; + }); + + appLogger.d('Loaded ${playlists.length} playlists'); + } catch (e) { + appLogger.e('Failed to load playlists', error: e); + setState(() { + _errorMessage = 'Failed to load playlists: ${e.toString()}'; + _isLoading = false; + }); + } + } + + void _toggleSmartFilter() { + setState(() { + if (_filterSmart == null) { + _filterSmart = true; // Show only smart + } else if (_filterSmart == true) { + _filterSmart = false; // Show only regular + } else { + _filterSmart = null; // Show all + } + }); + _loadPlaylists(); + } + + String _getFilterLabel() { + if (_filterSmart == null) return 'All'; + if (_filterSmart == true) return 'Smart'; + return 'Regular'; + } + + @override + void refresh() { + _loadPlaylists(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: CustomScrollView( + slivers: [ + CustomAppBar( + title: Text(t.playlists.title), + pinned: true, + actions: [ + TextButton.icon( + icon: const Icon(Icons.filter_list), + label: Text(_getFilterLabel()), + onPressed: _toggleSmartFilter, + ), + ], + ), + if (_errorMessage != null) + SliverFillRemaining( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.error_outline, + size: 48, + color: Colors.red, + ), + const SizedBox(height: 16), + Text(_errorMessage!), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadPlaylists, + child: Text(t.common.retry), + ), + ], + ), + ), + ) + else if (_playlists.isEmpty && _isLoading) + const SliverFillRemaining( + child: Center(child: CircularProgressIndicator()), + ) + else if (_playlists.isEmpty) + SliverFillRemaining( + child: Center(child: Text(t.playlists.noPlaylists)), + ) + else + SliverPadding( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), + sliver: SliverGrid( + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: _getMaxCrossAxisExtent( + context, + context.watch().libraryDensity, + ), + childAspectRatio: 2 / 3.3, + crossAxisSpacing: 0, + mainAxisSpacing: 0, + ), + delegate: SliverChildBuilderDelegate((context, index) { + return _PlaylistCard( + playlist: _playlists[index], + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PlaylistDetailScreen( + playlist: _playlists[index], + ), + ), + ).then((_) => _loadPlaylists()); // Refresh on return + }, + onDeleted: _loadPlaylists, + ); + }, childCount: _playlists.length), + ), + ), + ], + ), + ); + } + + double _getMaxCrossAxisExtent(BuildContext context, LibraryDensity density) { + final screenWidth = MediaQuery.of(context).size.width; + final padding = 16.0; + final availableWidth = screenWidth - padding; + + if (screenWidth >= 900) { + double divisor; + double maxItemWidth; + + switch (density) { + case LibraryDensity.comfortable: + divisor = 6.5; + maxItemWidth = 280; + break; + case LibraryDensity.normal: + divisor = 8.0; + maxItemWidth = 200; + break; + case LibraryDensity.compact: + divisor = 10.0; + maxItemWidth = 160; + break; + } + + return (availableWidth / divisor).clamp(120, maxItemWidth); + } else if (screenWidth >= 600) { + double divisor; + double maxItemWidth; + + switch (density) { + case LibraryDensity.comfortable: + divisor = 4.5; + maxItemWidth = 220; + break; + case LibraryDensity.normal: + divisor = 5.5; + maxItemWidth = 180; + break; + case LibraryDensity.compact: + divisor = 7.0; + maxItemWidth = 140; + break; + } + + return (availableWidth / divisor).clamp(100, maxItemWidth); + } else { + double divisor; + + switch (density) { + case LibraryDensity.comfortable: + divisor = 2.2; + break; + case LibraryDensity.normal: + divisor = 2.8; + break; + case LibraryDensity.compact: + divisor = 3.5; + break; + } + + return availableWidth / divisor; + } + } +} + +/// Widget to display a single playlist card +class _PlaylistCard extends StatelessWidget { + final PlexPlaylist playlist; + final VoidCallback onTap; + final VoidCallback onDeleted; + + const _PlaylistCard({ + required this.playlist, + required this.onTap, + required this.onDeleted, + }); + + Future _showDeleteDialog(BuildContext context) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(t.playlists.deleteConfirm), + content: Text(t.playlists.deleteMessage(name: playlist.title)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(t.common.cancel), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: Text(t.playlists.delete), + style: TextButton.styleFrom(foregroundColor: Colors.red), + ), + ], + ), + ); + + if (confirmed == true && context.mounted) { + final client = context.clientSafe; + final success = await client.deletePlaylist(playlist.ratingKey); + + if (context.mounted) { + if (success) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.playlists.deleted)), + ); + onDeleted(); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.playlists.errorDeleting)), + ); + } + } + } + } + + @override + Widget build(BuildContext context) { + final client = context.clientSafe; + final imageUrl = playlist.displayImage != null + ? client.getThumbnailUrl(playlist.displayImage!) + : null; + + return Card( + clipBehavior: Clip.antiAlias, + margin: const EdgeInsets.all(4), + child: InkWell( + onTap: onTap, + onLongPress: () => _showDeleteDialog(context), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Playlist image + Expanded( + child: Stack( + fit: StackFit.expand, + children: [ + if (imageUrl != null) + Image.network( + imageUrl, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return _buildPlaceholder(); + }, + ) + else + _buildPlaceholder(), + // Smart playlist indicator + if (playlist.smart) + Positioned( + top: 4, + right: 4, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: Colors.blue.withOpacity(0.9), + borderRadius: BorderRadius.circular(4), + ), + child: const Icon( + Icons.auto_awesome, + size: 12, + color: Colors.white, + ), + ), + ), + ], + ), + ), + // Playlist info + Padding( + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + playlist.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontWeight: FontWeight.w500, + fontSize: 13, + ), + ), + const SizedBox(height: 4), + Row( + children: [ + Icon( + Icons.playlist_play, + size: 14, + color: Colors.grey[600], + ), + const SizedBox(width: 4), + Text( + playlist.leafCount != null && playlist.leafCount! > 0 + ? (playlist.leafCount == 1 + ? t.playlists.oneItem + : t.playlists.itemCount(count: playlist.leafCount!)) + : t.playlists.emptyPlaylist, + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildPlaceholder() { + return Container( + color: Colors.grey[850], + child: const Center( + child: Icon( + Icons.playlist_play, + size: 48, + color: Colors.grey, + ), + ), + ); + } +} diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 6f40aed7..06098ccb 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -284,10 +284,6 @@ class VideoPlayerScreenState extends State } Future _loadAdjacentEpisodes() async { - if (widget.metadata.type.toLowerCase() != 'episode') { - return; - } - try { final clientProvider = context.plexClient; final client = clientProvider.client; @@ -299,8 +295,25 @@ class VideoPlayerScreenState extends State PlexMetadata? next; PlexMetadata? previous; + // Check if playlist mode is active (takes priority) + if (playbackState.isPlaylistActive) { + // For playlists, always use the queue regardless of item type + // Playlists can contain both movies and episodes + next = playbackState.getNextEpisode( + widget.metadata.ratingKey, + loopQueue: false, // Don't loop playlists by default + ); + previous = playbackState.getPreviousEpisode( + widget.metadata.ratingKey, + ); + } // Check if shuffle mode is active - if (playbackState.isShuffleActive) { + else if (playbackState.isShuffleActive) { + // Only works for episodes in shuffle mode + if (widget.metadata.type.toLowerCase() != 'episode') { + return; + } + // Get settings final shuffleOrderNavigation = settingsProvider.shuffleOrderNavigation; final loopQueue = settingsProvider.shuffleLoopQueue; @@ -319,7 +332,14 @@ class VideoPlayerScreenState extends State next = await client.findAdjacentEpisode(widget.metadata, 1); previous = await client.findAdjacentEpisode(widget.metadata, -1); } - } else { + } + // Normal sequential playback + else { + // Only works for episodes in sequential mode + if (widget.metadata.type.toLowerCase() != 'episode') { + return; + } + // Use normal sequential episode loading next = await client.findAdjacentEpisode(widget.metadata, 1); previous = await client.findAdjacentEpisode(widget.metadata, -1); @@ -1483,16 +1503,20 @@ class VideoPlayerScreenState extends State } }); - // Enable/disable next/previous track controls based on content type + // Enable/disable next/previous track controls based on content type and playback mode + final playbackState = context.read(); final isEpisode = widget.metadata.type.toLowerCase() == 'episode'; - if (isEpisode) { - // Enable next/previous track controls for episodes + final isInPlaylist = playbackState.isPlaylistActive; + + // Enable controls for episodes OR playlist items + if (isEpisode || isInPlaylist) { + // Enable next/previous track controls for episodes and playlist items await OsMediaControls.enableControls([ MediaControl.next, MediaControl.previous, ]); } else { - // Disable next/previous track controls for movies + // Disable next/previous track controls for standalone movies await OsMediaControls.disableControls([ MediaControl.next, MediaControl.previous, diff --git a/lib/services/server_connection_service.dart b/lib/services/server_connection_service.dart index 5a3dbea2..c760039c 100644 --- a/lib/services/server_connection_service.dart +++ b/lib/services/server_connection_service.dart @@ -112,6 +112,18 @@ class ServerConnectionService { }, ); + // Fetch machine identifier and cache it in config + try { + final machineId = await client.getMachineIdentifier(); + if (machineId != null) { + client.config = config.copyWith(machineIdentifier: machineId); + appLogger.d('Cached machine identifier: $machineId'); + } + } catch (e) { + appLogger.w('Failed to fetch machine identifier', error: e); + // Continue without it - buildMetadataUri will fallback to fetching it + } + // Verify server is accessible if requested if (verifyServer) { try { diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index a186469d..68e6c39b 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -1,7 +1,9 @@ import 'dart:io'; import 'package:flutter/material.dart'; import '../models/plex_metadata.dart'; +import '../models/plex_playlist.dart'; import '../utils/provider_extensions.dart'; +import '../utils/app_logger.dart'; import '../screens/media_detail_screen.dart'; import '../screens/season_detail_screen.dart'; import '../widgets/file_info_bottom_sheet.dart'; @@ -143,6 +145,17 @@ class _MediaContextMenuState extends State { ); } + // Add to Playlist (for episodes, movies, shows, and seasons) + if (itemType == 'episode' || itemType == 'movie' || itemType == 'show' || itemType == 'season') { + menuActions.add( + _MenuAction( + value: 'add_to_playlist', + icon: Icons.playlist_add, + label: t.playlists.addTo, + ), + ); + } + String? selected; if (useBottomSheet) { @@ -296,6 +309,10 @@ class _MediaContextMenuState extends State { await _showFileInfo(context); break; + case 'add_to_playlist': + await _showAddToPlaylistDialog(context); + break; + case 'shuffle_play': await handleShufflePlay(context, widget.metadata); break; @@ -412,6 +429,100 @@ class _MediaContextMenuState extends State { } } + /// Show dialog to select playlist and add item + Future _showAddToPlaylistDialog(BuildContext context) async { + final client = context.client; + if (client == null) return; + + try { + final itemType = widget.metadata.type.toLowerCase(); + + // Load playlists + final playlists = await client.getPlaylists(playlistType: 'video'); + + if (!context.mounted) return; + + // Show dialog to select playlist or create new + final result = await showDialog( + context: context, + builder: (context) => _PlaylistSelectionDialog( + playlists: playlists, + ), + ); + + if (result == null || !context.mounted) return; + + // Build URI for the item (works for all types: movies, episodes, seasons, shows) + // For seasons/shows, the Plex API should automatically expand to include all episodes + final itemUri = await client.buildMetadataUri(widget.metadata.ratingKey); + appLogger.d('Built URI for $itemType: $itemUri'); + + if (result == '_create_new') { + // Create new playlist flow + final playlistName = await showDialog( + context: context, + builder: (context) => _CreatePlaylistDialog(), + ); + + if (playlistName == null || playlistName.isEmpty || !context.mounted) { + return; + } + + // Create playlist with the item(s) + appLogger.d('Creating playlist "$playlistName" with URI length: ${itemUri.length}'); + final newPlaylist = await client.createPlaylist( + title: playlistName, + uri: itemUri, + ); + + if (context.mounted) { + if (newPlaylist != null) { + appLogger.d('Successfully created playlist: ${newPlaylist.title}'); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.playlists.created)), + ); + } else { + appLogger.e('Failed to create playlist - API returned null'); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.playlists.errorCreating)), + ); + } + } + } else { + // Add to existing playlist + appLogger.d('Adding to playlist $result with URI: $itemUri'); + final success = await client.addToPlaylist( + playlistId: result, + uri: itemUri, + ); + + if (context.mounted) { + if (success) { + appLogger.d('Successfully added item(s) to playlist $result'); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.playlists.itemAdded)), + ); + } else { + appLogger.e('Failed to add item(s) to playlist $result - API returned false'); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.playlists.errorAdding)), + ); + } + } + } + } catch (e, stackTrace) { + appLogger.e('Error in add to playlist flow', error: e, stackTrace: stackTrace); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('${t.playlists.errorLoading}: ${e.toString()}'), + duration: const Duration(seconds: 5), + ), + ); + } + } + } + @override Widget build(BuildContext context) { return GestureDetector( @@ -424,3 +535,107 @@ class _MediaContextMenuState extends State { ); } } + +/// Dialog to select a playlist or create a new one +class _PlaylistSelectionDialog extends StatelessWidget { + final List playlists; + + const _PlaylistSelectionDialog({required this.playlists}); + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(t.playlists.selectPlaylist), + content: SizedBox( + width: double.maxFinite, + child: ListView.builder( + shrinkWrap: true, + itemCount: playlists.length + 1, + itemBuilder: (context, index) { + if (index == 0) { + // Create new playlist option (always shown first) + return ListTile( + leading: const Icon(Icons.add), + title: Text(t.playlists.createNewPlaylist), + onTap: () => Navigator.pop(context, '_create_new'), + ); + } + + final playlist = playlists[index - 1]; + return ListTile( + leading: playlist.smart + ? const Icon(Icons.auto_awesome) + : const Icon(Icons.playlist_play), + title: Text(playlist.title), + subtitle: playlist.leafCount != null + ? Text(playlist.leafCount == 1 + ? t.playlists.oneItem + : t.playlists.itemCount(count: playlist.leafCount!)) + : null, + onTap: playlist.smart + ? null // Disable smart playlists + : () => Navigator.pop(context, playlist.ratingKey), + enabled: !playlist.smart, + ); + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(t.common.cancel), + ), + ], + ); + } +} + +/// Dialog to create a new playlist +class _CreatePlaylistDialog extends StatefulWidget { + @override + State<_CreatePlaylistDialog> createState() => _CreatePlaylistDialogState(); +} + +class _CreatePlaylistDialogState extends State<_CreatePlaylistDialog> { + final _controller = TextEditingController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(t.playlists.create), + content: TextField( + controller: _controller, + autofocus: true, + decoration: InputDecoration( + labelText: t.playlists.playlistName, + hintText: t.playlists.enterPlaylistName, + ), + onSubmitted: (value) { + if (value.isNotEmpty) { + Navigator.pop(context, value); + } + }, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(t.common.cancel), + ), + TextButton( + onPressed: () { + if (_controller.text.isNotEmpty) { + Navigator.pop(context, _controller.text); + } + }, + child: Text(t.common.save), + ), + ], + ); + } +} diff --git a/lib/widgets/playlist_item_card.dart b/lib/widgets/playlist_item_card.dart new file mode 100644 index 00000000..0cbb404b --- /dev/null +++ b/lib/widgets/playlist_item_card.dart @@ -0,0 +1,199 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import '../models/plex_metadata.dart'; +import '../providers/plex_client_provider.dart'; +import '../i18n/strings.g.dart'; + +/// Custom list item widget for playlist items +/// Shows drag handle, poster, title/metadata, duration, and remove button +class PlaylistItemCard extends StatelessWidget { + final PlexMetadata item; + final int index; + final VoidCallback onRemove; + final VoidCallback? onTap; + final bool canReorder; // Whether drag handle should be shown + + const PlaylistItemCard({ + super.key, + required this.item, + required this.index, + required this.onRemove, + this.onTap, + this.canReorder = true, + }); + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + // Drag handle (if reorderable) + if (canReorder) + ReorderableDragStartListener( + index: index, + child: const Padding( + padding: EdgeInsets.only(right: 12), + child: Icon( + Icons.drag_indicator, + color: Colors.grey, + ), + ), + ), + + // Poster thumbnail + _buildPosterImage(context), + + const SizedBox(width: 12), + + // Title and metadata + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Title + Text( + item.displayTitle, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + + const SizedBox(height: 4), + + // Subtitle (episode info or type) + Text( + _buildSubtitle(), + style: TextStyle( + fontSize: 13, + color: Colors.grey[400], + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + + // Progress indicator if partially watched + if (item.viewOffset != null && item.duration != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: LinearProgressIndicator( + value: item.viewOffset! / item.duration!, + backgroundColor: Colors.grey[800], + valueColor: AlwaysStoppedAnimation( + Theme.of(context).colorScheme.primary, + ), + minHeight: 3, + ), + ), + ], + ), + ), + + const SizedBox(width: 12), + + // Duration + if (item.duration != null) + Text( + _formatDuration(item.duration!), + style: TextStyle( + fontSize: 13, + color: Colors.grey[400], + ), + ), + + const SizedBox(width: 8), + + // Remove button + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: onRemove, + tooltip: t.playlists.removeItem, + color: Colors.grey[400], + ), + ], + ), + ), + ), + ); + } + + Widget _buildPosterImage(BuildContext context) { + final posterUrl = item.posterThumb(); + if (posterUrl != null) { + return Consumer( + builder: (context, clientProvider, child) { + final client = clientProvider.client; + if (client == null) { + return _buildPlaceholder(); + } + + return ClipRRect( + borderRadius: BorderRadius.circular(6), + child: CachedNetworkImage( + imageUrl: client.getThumbnailUrl(posterUrl), + width: 60, + height: 90, + fit: BoxFit.cover, + placeholder: (context, url) => _buildPlaceholder(), + errorWidget: (context, url, error) => _buildPlaceholder(), + ), + ); + }, + ); + } + return _buildPlaceholder(); + } + + Widget _buildPlaceholder() { + return Container( + width: 60, + height: 90, + decoration: BoxDecoration( + color: Colors.grey[850], + borderRadius: BorderRadius.circular(6), + ), + child: const Icon(Icons.movie, color: Colors.grey, size: 24), + ); + } + + String _buildSubtitle() { + final itemType = item.type.toLowerCase(); + + if (itemType == 'episode') { + // For episodes, show "S#E# - Episode Title" + final season = item.parentIndex; + final episode = item.index; + if (season != null && episode != null) { + return 'S${season}E$episode${item.displaySubtitle != null ? ' - ${item.displaySubtitle}' : ''}'; + } + return item.displaySubtitle ?? t.discover.tvShow; + } else if (itemType == 'movie') { + // For movies, show year + return item.year?.toString() ?? t.discover.movie; + } + + // Default to type + return item.type; + } + + String _formatDuration(int milliseconds) { + final duration = Duration(milliseconds: milliseconds); + final hours = duration.inHours; + final minutes = duration.inMinutes.remainder(60); + + if (hours > 0) { + return '${hours}h ${minutes}m'; + } else { + return '${minutes}m'; + } + } +} diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 3035b5b1..c77fe217 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -1,4 +1,6 @@ PODS: + - connectivity_plus (0.0.1): + - FlutterMacOS - FlutterMacOS (1.0.0) - HotKey (0.2.1) - hotkey_manager_macos (0.0.1): @@ -36,6 +38,7 @@ PODS: - FlutterMacOS DEPENDENCIES: + - connectivity_plus (from `Flutter/ephemeral/.symlinks/plugins/connectivity_plus/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`) @@ -57,6 +60,8 @@ SPEC REPOS: - HotKey EXTERNAL SOURCES: + connectivity_plus: + :path: Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos FlutterMacOS: :path: Flutter/ephemeral hotkey_manager_macos: @@ -89,6 +94,7 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos SPEC CHECKSUMS: + connectivity_plus: 4adf20a405e25b42b9c9f87feff8f4b6fde18a4e FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 HotKey: 400beb7caa29054ea8d864c96f5ba7e5b4852277 hotkey_manager_macos: a4317849af96d2430fa89944d3c58977ca089fbe From f14ed7d762b383b321192e15657ca41033194978 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 15 Nov 2025 04:08:57 +0100 Subject: [PATCH 17/29] chore: format --- .../endpoint_failover_interceptor.dart | 9 +-- lib/providers/playback_state_provider.dart | 3 +- lib/screens/libraries_screen.dart | 4 +- lib/screens/playlist_detail_screen.dart | 77 ++++++++++--------- lib/screens/playlists_screen.dart | 34 ++++---- lib/screens/video_player_screen.dart | 8 +- lib/services/plex_auth_service.dart | 35 ++++----- lib/services/server_connection_service.dart | 72 ++++++++--------- lib/utils/log_redaction_manager.dart | 13 ++-- lib/widgets/media_context_menu.dart | 53 ++++++++----- lib/widgets/playlist_item_card.dart | 15 +--- 11 files changed, 155 insertions(+), 168 deletions(-) diff --git a/lib/network/endpoint_failover_interceptor.dart b/lib/network/endpoint_failover_interceptor.dart index 71c683da..e0ecd011 100644 --- a/lib/network/endpoint_failover_interceptor.dart +++ b/lib/network/endpoint_failover_interceptor.dart @@ -25,10 +25,7 @@ class EndpointFailoverManager { } /// Replace the endpoint list and optionally set the active endpoint. - void reset( - List urls, { - String? currentBaseUrl, - }) { + void reset(List urls, {String? currentBaseUrl}) { _setEndpoints(urls); if (currentBaseUrl != null) { final index = _endpoints.indexOf(currentBaseUrl); @@ -60,8 +57,8 @@ class EndpointFailoverInterceptor extends Interceptor { required Dio dio, required this.endpointManager, required Future Function(String newBaseUrl) onEndpointSwitch, - }) : _dio = dio, - _onEndpointSwitch = onEndpointSwitch; + }) : _dio = dio, + _onEndpointSwitch = onEndpointSwitch; final Dio _dio; final EndpointFailoverManager endpointManager; diff --git a/lib/providers/playback_state_provider.dart b/lib/providers/playback_state_provider.dart index c13d7c3f..b994af6c 100644 --- a/lib/providers/playback_state_provider.dart +++ b/lib/providers/playback_state_provider.dart @@ -27,7 +27,8 @@ class PlaybackStateProvider with ChangeNotifier { bool get isPlaylistActive => _playbackMode == PlaybackMode.playlist; /// Whether any queue-based playback is active - bool get isQueueActive => _queue.isNotEmpty && _playbackMode != PlaybackMode.none; + bool get isQueueActive => + _queue.isNotEmpty && _playbackMode != PlaybackMode.none; /// The context key (show/season/playlist ratingKey) for the current session String? get shuffleContextKey => _contextKey; diff --git a/lib/screens/libraries_screen.dart b/lib/screens/libraries_screen.dart index cf5a912e..ff02bde8 100644 --- a/lib/screens/libraries_screen.dart +++ b/lib/screens/libraries_screen.dart @@ -1130,9 +1130,7 @@ class _LibrariesScreenState extends State onPressed: () { Navigator.push( context, - MaterialPageRoute( - builder: (context) => const PlaylistsScreen(), - ), + MaterialPageRoute(builder: (context) => const PlaylistsScreen()), ); }, tooltip: t.playlists.title, diff --git a/lib/screens/playlist_detail_screen.dart b/lib/screens/playlist_detail_screen.dart index c96af4ec..bcd25c25 100644 --- a/lib/screens/playlist_detail_screen.dart +++ b/lib/screens/playlist_detail_screen.dart @@ -61,7 +61,9 @@ class _PlaylistDetailScreenState extends State _isLoading = false; }); - appLogger.d('Loaded ${items.length} items for playlist: ${widget.playlist.title}'); + appLogger.d( + 'Loaded ${items.length} items for playlist: ${widget.playlist.title}', + ); } catch (e) { appLogger.e('Failed to load playlist items', error: e); setState(() { @@ -96,14 +98,14 @@ class _PlaylistDetailScreenState extends State if (mounted) { if (success) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(t.playlists.deleted)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.playlists.deleted))); Navigator.pop(context); // Return to playlists screen } else { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(t.playlists.errorDeleting)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.playlists.errorDeleting))); } } } @@ -124,9 +126,9 @@ class _PlaylistDetailScreenState extends State if (movedItem.playlistItemID == null) { appLogger.e('Cannot reorder: item missing playlistItemID'); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(t.playlists.errorReordering)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.playlists.errorReordering))); } return; } @@ -142,16 +144,18 @@ class _PlaylistDetailScreenState extends State if (afterItem.playlistItemID == null) { appLogger.e('Cannot reorder: after item missing playlistItemID'); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(t.playlists.errorReordering)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.playlists.errorReordering))); } return; } afterPlaylistItemId = afterItem.playlistItemID!; } - appLogger.d('Reordering item from $oldIndex to $newIndex (after ID: $afterPlaylistItemId)'); + appLogger.d( + 'Reordering item from $oldIndex to $newIndex (after ID: $afterPlaylistItemId)', + ); // Optimistically update UI setState(() { @@ -175,9 +179,9 @@ class _PlaylistDetailScreenState extends State _items.insert(oldIndex, item); }); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(t.playlists.errorReordering)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.playlists.errorReordering))); } } } @@ -189,14 +193,16 @@ class _PlaylistDetailScreenState extends State if (item.playlistItemID == null) { appLogger.e('Cannot remove: item missing playlistItemID'); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(t.playlists.errorRemoving)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.playlists.errorRemoving))); } return; } - appLogger.d('Removing item ${item.title} (playlistItemID: ${item.playlistItemID}) from playlist'); + appLogger.d( + 'Removing item ${item.title} (playlistItemID: ${item.playlistItemID}) from playlist', + ); // Optimistically update UI setState(() { @@ -211,9 +217,9 @@ class _PlaylistDetailScreenState extends State if (mounted) { if (success) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(t.playlists.itemRemoved)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.playlists.itemRemoved))); } else { // Revert on failure appLogger.e('Failed to remove playlist item, reverting UI'); @@ -221,9 +227,9 @@ class _PlaylistDetailScreenState extends State _items.insert(index, item); }); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(t.playlists.errorRemoving)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.playlists.errorRemoving))); } } } @@ -244,9 +250,9 @@ class _PlaylistDetailScreenState extends State Future _playPlaylist() async { if (_items.isEmpty) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(t.playlists.emptyPlaylist)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.playlists.emptyPlaylist))); } return; } @@ -265,9 +271,9 @@ class _PlaylistDetailScreenState extends State Future _shufflePlayPlaylist() async { if (_items.isEmpty) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(t.playlists.emptyPlaylist)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.playlists.emptyPlaylist))); } return; } @@ -422,10 +428,7 @@ class _PlaylistDetailScreenState extends State mainAxisSpacing: 0, ), delegate: SliverChildBuilderDelegate((context, index) { - return MediaCard( - item: _items[index], - onRefresh: updateItem, - ); + return MediaCard(item: _items[index], onRefresh: updateItem); }, childCount: _items.length), ), ) diff --git a/lib/screens/playlists_screen.dart b/lib/screens/playlists_screen.dart index 82fad9f0..16166ef8 100644 --- a/lib/screens/playlists_screen.dart +++ b/lib/screens/playlists_screen.dart @@ -156,9 +156,8 @@ class _PlaylistsScreenState extends State with Refreshable { Navigator.push( context, MaterialPageRoute( - builder: (context) => PlaylistDetailScreen( - playlist: _playlists[index], - ), + builder: (context) => + PlaylistDetailScreen(playlist: _playlists[index]), ), ).then((_) => _loadPlaylists()); // Refresh on return }, @@ -275,14 +274,14 @@ class _PlaylistCard extends StatelessWidget { if (context.mounted) { if (success) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(t.playlists.deleted)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.playlists.deleted))); onDeleted(); } else { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(t.playlists.errorDeleting)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.playlists.errorDeleting))); } } } @@ -370,13 +369,12 @@ class _PlaylistCard extends StatelessWidget { Text( playlist.leafCount != null && playlist.leafCount! > 0 ? (playlist.leafCount == 1 - ? t.playlists.oneItem - : t.playlists.itemCount(count: playlist.leafCount!)) + ? t.playlists.oneItem + : t.playlists.itemCount( + count: playlist.leafCount!, + )) : t.playlists.emptyPlaylist, - style: TextStyle( - fontSize: 12, - color: Colors.grey[600], - ), + style: TextStyle(fontSize: 12, color: Colors.grey[600]), ), ], ), @@ -393,11 +391,7 @@ class _PlaylistCard extends StatelessWidget { return Container( color: Colors.grey[850], child: const Center( - child: Icon( - Icons.playlist_play, - size: 48, - color: Colors.grey, - ), + child: Icon(Icons.playlist_play, size: 48, color: Colors.grey), ), ); } diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 06098ccb..0eb7a339 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -135,7 +135,9 @@ class VideoPlayerScreenState extends State // Clear media controls when app goes to background or screen locks // (we don't support background playback) OsMediaControls.clear(); - appLogger.d('Media controls cleared due to app lifecycle state: $state'); + appLogger.d( + 'Media controls cleared due to app lifecycle state: $state', + ); break; case AppLifecycleState.resumed: // Restore media controls when app is resumed @@ -303,9 +305,7 @@ class VideoPlayerScreenState extends State widget.metadata.ratingKey, loopQueue: false, // Don't loop playlists by default ); - previous = playbackState.getPreviousEpisode( - widget.metadata.ratingKey, - ); + previous = playbackState.getPreviousEpisode(widget.metadata.ratingKey); } // Check if shuffle mode is active else if (playbackState.isShuffleActive) { diff --git a/lib/services/plex_auth_service.dart b/lib/services/plex_auth_service.dart index a380cc9c..f649a9ee 100644 --- a/lib/services/plex_auth_service.dart +++ b/lib/services/plex_auth_service.dart @@ -418,10 +418,7 @@ class PlexServer { final totalCandidates = candidates.length; appLogger.d( 'Starting server connection discovery', - error: { - 'preferred': preferredUri, - 'candidateCount': totalCandidates, - }, + error: {'preferred': preferredUri, 'candidateCount': totalCandidates}, ); _ConnectionCandidate? firstCandidate; @@ -466,13 +463,11 @@ class PlexServer { ); for (final candidate in candidates) { - PlexClient - .testConnectionWithLatency( + PlexClient.testConnectionWithLatency( candidate.url, accessToken, timeout: raceTimeout, - ) - .then((result) { + ).then((result) { completedTests++; if (result.success && !completer.isCompleted) { @@ -545,7 +540,7 @@ class PlexServer { if (bestCandidate != null) { final upgradedCandidate = await _upgradeCandidateToHttpsIfPossible(bestCandidate) ?? - bestCandidate; + bestCandidate; final bestConnection = _updateConnectionUrl( upgradedCandidate.connection, @@ -596,8 +591,7 @@ class PlexServer { if (uri == url) { final isHttps = uri.startsWith('https://'); final parsedHost = Uri.tryParse(uri)?.host ?? ''; - final isPlexDirect = - parsedHost.toLowerCase().contains('plex.direct'); + final isPlexDirect = parsedHost.toLowerCase().contains('plex.direct'); return _ConnectionCandidate(connection, uri, isPlexDirect, isHttps); } } @@ -644,18 +638,14 @@ class PlexServer { return; } seen.add(url); - bucketFor(connection, isHttps).add( - _ConnectionCandidate(connection, url, isPlexDirectUri, isHttps), - ); + bucketFor( + connection, + isHttps, + ).add(_ConnectionCandidate(connection, url, isPlexDirectUri, isHttps)); } for (final connection in connections) { - addCandidate( - connection, - connection.httpDirectUrl, - false, - false, - ); + addCandidate(connection, connection.httpDirectUrl, false, false); } return [ @@ -789,8 +779,9 @@ class PlexServer { current.uri.contains('.plex.direct'), current.uri.startsWith('https://'), ); - final upgradedCandidate = - await _upgradeCandidateToHttpsIfPossible(candidate); + final upgradedCandidate = await _upgradeCandidateToHttpsIfPossible( + candidate, + ); if (upgradedCandidate == null) { return null; } diff --git a/lib/services/server_connection_service.dart b/lib/services/server_connection_service.dart index c760039c..5f7d23f1 100644 --- a/lib/services/server_connection_service.dart +++ b/lib/services/server_connection_service.dart @@ -24,7 +24,7 @@ class ServerConnectionResult { /// Implements fast-first connection with background optimization class ServerConnectionService { static StreamSubscription>? - _connectivitySubscription; + _connectivitySubscription; static Future? _activeOptimization; static PlexServer? _activeServer; static PlexClient? _activeClient; @@ -51,28 +51,31 @@ class ServerConnectionService { }) async { final storage = await StorageService.getInstance(); - final connectionStream = - server.findBestWorkingConnection().asBroadcastStream(); + final connectionStream = server + .findBestWorkingConnection() + .asBroadcastStream(); PlexClient? client; - final optimizationSubscription = connectionStream.skip(1).listen( - (connection) async { - await _handleOptimizedConnection( - connection: connection, - storage: storage, - server: server, - client: client, - reason: 'initial_latency_sweep', + final optimizationSubscription = connectionStream + .skip(1) + .listen( + (connection) async { + await _handleOptimizedConnection( + connection: connection, + storage: storage, + server: server, + client: client, + reason: 'initial_latency_sweep', + ); + }, + onError: (error, stackTrace) { + appLogger.w( + 'Background connection optimization error', + error: error, + stackTrace: stackTrace, + ); + }, ); - }, - onError: (error, stackTrace) { - appLogger.w( - 'Background connection optimization error', - error: error, - stackTrace: stackTrace, - ); - }, - ); try { final connection = await connectionStream.first; @@ -132,9 +135,7 @@ class ServerConnectionService { await optimizationSubscription.cancel(); appLogger.w('Server identity verification failed', error: e); await storage.clearCredentials(); - return ServerConnectionResult( - error: 'Server is not accessible: $e', - ); + return ServerConnectionResult(error: 'Server is not accessible: $e'); } } @@ -149,10 +150,7 @@ class ServerConnectionService { _activeClient = client; _startConnectivityMonitoring(server); - return ServerConnectionResult( - client: client, - userProfile: userProfile, - ); + return ServerConnectionResult(client: client, userProfile: userProfile); } on StateError catch (e, stackTrace) { await optimizationSubscription.cancel(); appLogger.e( @@ -205,8 +203,9 @@ class ServerConnectionService { final connectivity = Connectivity(); _connectivitySubscription = connectivity.onConnectivityChanged.listen( (results) { - final status = - results.isNotEmpty ? results.first : ConnectivityResult.none; + final status = results.isNotEmpty + ? results.first + : ConnectivityResult.none; if (status == ConnectivityResult.none) { appLogger.w( 'Connectivity lost, pausing optimization until network returns', @@ -250,13 +249,14 @@ class ServerConnectionService { return; } - _activeOptimization = _runOptimization( - server: _activeServer!, - client: _activeClient, - reason: reason, - ).whenComplete(() { - _activeOptimization = null; - }); + _activeOptimization = + _runOptimization( + server: _activeServer!, + client: _activeClient, + reason: reason, + ).whenComplete(() { + _activeOptimization = null; + }); } static Future _runOptimization({ diff --git a/lib/utils/log_redaction_manager.dart b/lib/utils/log_redaction_manager.dart index f0aff940..15437c9e 100644 --- a/lib/utils/log_redaction_manager.dart +++ b/lib/utils/log_redaction_manager.dart @@ -2,10 +2,10 @@ class LogRedactionManager { static final Set _tokens = {}; static final Set _urls = {}; static final Set _customValues = {}; - static final RegExp _ipv4Pattern = - RegExp(r'\b(\d{1,3})([.-])(\d{1,3})\2(\d{1,3})\2(\d{1,3})\b'); - static final RegExp _ipv4HostPattern = - RegExp(r'^\d{1,3}([.-]\d{1,3}){3}$'); + static final RegExp _ipv4Pattern = RegExp( + r'\b(\d{1,3})([.-])(\d{1,3})\2(\d{1,3})\2(\d{1,3})\b', + ); + static final RegExp _ipv4HostPattern = RegExp(r'^\d{1,3}([.-]\d{1,3}){3}$'); /// Register a server access token or Plex.tv token for redaction. static void registerToken(String? token) { @@ -129,8 +129,9 @@ class LogRedactionManager { ? (url.length / 2).ceil() : startPreviewLength; final remainingForEnd = url.length - startLength; - final endLength = - remainingForEnd <= endPreviewLength ? remainingForEnd : endPreviewLength; + final endLength = remainingForEnd <= endPreviewLength + ? remainingForEnd + : endPreviewLength; final start = url.substring(0, startLength); if (endLength <= 0) { diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index 68e6c39b..66c72186 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -146,7 +146,10 @@ class _MediaContextMenuState extends State { } // Add to Playlist (for episodes, movies, shows, and seasons) - if (itemType == 'episode' || itemType == 'movie' || itemType == 'show' || itemType == 'season') { + if (itemType == 'episode' || + itemType == 'movie' || + itemType == 'show' || + itemType == 'season') { menuActions.add( _MenuAction( value: 'add_to_playlist', @@ -445,9 +448,7 @@ class _MediaContextMenuState extends State { // Show dialog to select playlist or create new final result = await showDialog( context: context, - builder: (context) => _PlaylistSelectionDialog( - playlists: playlists, - ), + builder: (context) => _PlaylistSelectionDialog(playlists: playlists), ); if (result == null || !context.mounted) return; @@ -469,7 +470,9 @@ class _MediaContextMenuState extends State { } // Create playlist with the item(s) - appLogger.d('Creating playlist "$playlistName" with URI length: ${itemUri.length}'); + appLogger.d( + 'Creating playlist "$playlistName" with URI length: ${itemUri.length}', + ); final newPlaylist = await client.createPlaylist( title: playlistName, uri: itemUri, @@ -478,14 +481,14 @@ class _MediaContextMenuState extends State { if (context.mounted) { if (newPlaylist != null) { appLogger.d('Successfully created playlist: ${newPlaylist.title}'); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(t.playlists.created)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.playlists.created))); } else { appLogger.e('Failed to create playlist - API returned null'); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(t.playlists.errorCreating)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.playlists.errorCreating))); } } } else { @@ -499,19 +502,25 @@ class _MediaContextMenuState extends State { if (context.mounted) { if (success) { appLogger.d('Successfully added item(s) to playlist $result'); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(t.playlists.itemAdded)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.playlists.itemAdded))); } else { - appLogger.e('Failed to add item(s) to playlist $result - API returned false'); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(t.playlists.errorAdding)), + appLogger.e( + 'Failed to add item(s) to playlist $result - API returned false', ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.playlists.errorAdding))); } } } } catch (e, stackTrace) { - appLogger.e('Error in add to playlist flow', error: e, stackTrace: stackTrace); + appLogger.e( + 'Error in add to playlist flow', + error: e, + stackTrace: stackTrace, + ); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -568,9 +577,11 @@ class _PlaylistSelectionDialog extends StatelessWidget { : const Icon(Icons.playlist_play), title: Text(playlist.title), subtitle: playlist.leafCount != null - ? Text(playlist.leafCount == 1 - ? t.playlists.oneItem - : t.playlists.itemCount(count: playlist.leafCount!)) + ? Text( + playlist.leafCount == 1 + ? t.playlists.oneItem + : t.playlists.itemCount(count: playlist.leafCount!), + ) : null, onTap: playlist.smart ? null // Disable smart playlists diff --git a/lib/widgets/playlist_item_card.dart b/lib/widgets/playlist_item_card.dart index 0cbb404b..234ae989 100644 --- a/lib/widgets/playlist_item_card.dart +++ b/lib/widgets/playlist_item_card.dart @@ -39,10 +39,7 @@ class PlaylistItemCard extends StatelessWidget { index: index, child: const Padding( padding: EdgeInsets.only(right: 12), - child: Icon( - Icons.drag_indicator, - color: Colors.grey, - ), + child: Icon(Icons.drag_indicator, color: Colors.grey), ), ), @@ -73,10 +70,7 @@ class PlaylistItemCard extends StatelessWidget { // Subtitle (episode info or type) Text( _buildSubtitle(), - style: TextStyle( - fontSize: 13, - color: Colors.grey[400], - ), + style: TextStyle(fontSize: 13, color: Colors.grey[400]), maxLines: 1, overflow: TextOverflow.ellipsis, ), @@ -104,10 +98,7 @@ class PlaylistItemCard extends StatelessWidget { if (item.duration != null) Text( _formatDuration(item.duration!), - style: TextStyle( - fontSize: 13, - color: Colors.grey[400], - ), + style: TextStyle(fontSize: 13, color: Colors.grey[400]), ), const SizedBox(width: 8), From 8ee2b7cfe13ecff05fc40d6d8aeb695734fb0402 Mon Sep 17 00:00:00 2001 From: Jamal <15986930+jamal2362@users.noreply.github.com> Date: Sat, 15 Nov 2025 12:51:57 +0100 Subject: [PATCH 18/29] Added more missing translations --- lib/i18n/strings.g.dart | 276 ++++++++++++++++---------- lib/i18n/strings.i18n.json | 7 +- lib/i18n/strings_de.i18n.json | 113 ++++++----- lib/i18n/strings_it.i18n.json | 7 +- lib/i18n/strings_nl.i18n.json | 7 +- lib/i18n/strings_sv.i18n.json | 7 +- lib/i18n/strings_zh.i18n.json | 7 +- lib/screens/media_detail_screen.dart | 6 +- lib/screens/season_detail_screen.dart | 2 +- lib/widgets/profile_list_tile.dart | 42 ++-- 10 files changed, 289 insertions(+), 185 deletions(-) diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index dbf4b3d0..d95497f8 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 6 -/// Strings: 2100 (350 per locale) +/// Strings: 2130 (355 per locale) /// -/// Built on 2025-11-15 at 02:45 UTC +/// Built on 2025-11-15 at 11:48 UTC // coverage:ignore-file // ignore_for_file: type=lint @@ -485,6 +485,7 @@ class _StringsUserStatusEn { String get admin => 'Admin'; String get restricted => 'Restricted'; String get protected => 'Protected'; + String get current => 'CURRENT'; } // Path: messages @@ -583,6 +584,10 @@ class _StringsDiscoverEn { String get pause => 'Pause'; String get overview => 'Overview'; String get cast => 'Cast'; + String get seasons => 'Seasons'; + String get studio => 'Studio'; + String get rating => 'Rating'; + String get watched => 'Watched'; String episodeCount({required Object count}) => '${count} episodes'; String watchedProgress({required Object watched, required Object total}) => '${watched}/${total} watched'; String get movie => 'Movie'; @@ -889,7 +894,7 @@ class _StringsCommonDe implements _StringsCommonEn { @override String get logout => 'Abmelden'; @override String get online => 'Online'; @override String get offline => 'Offline'; - @override String get owned => 'Eigentum'; + @override String get owned => 'Besitzer'; @override String get shared => 'Geteilt'; @override String get current => 'AKTUELL'; @override String get unknown => 'Unbekannt'; @@ -939,7 +944,7 @@ class _StringsSettingsDe implements _StringsSettingsEn { // Translations @override String get title => 'Einstellungen'; @override String get language => 'Sprache'; - @override String get theme => 'Theme'; + @override String get theme => 'Design'; @override String get appearance => 'Darstellung'; @override String get videoPlayback => 'Videowiedergabe'; @override String get shufflePlay => 'Zufallswiedergabe'; @@ -955,7 +960,7 @@ class _StringsSettingsDe implements _StringsSettingsEn { @override String get systemThemeDescription => 'Systemeinstellungen folgen'; @override String get lightTheme => 'Hell'; @override String get darkTheme => 'Dunkel'; - @override String get libraryDensity => 'Bibliotheksdichte'; + @override String get libraryDensity => 'Mediathekdichte'; @override String get compact => 'Kompakt'; @override String get compactDescription => 'Kleinere Karten, mehr Elemente sichtbar'; @override String get normal => 'Normal'; @@ -970,7 +975,7 @@ class _StringsSettingsDe implements _StringsSettingsEn { @override String get useSeasonPosters => 'Staffelposter verwenden'; @override String get showHeroSection => 'Hero-Bereich anzeigen'; @override String get hardwareDecoding => 'Hardware-Decodierung'; - @override String get hardwareDecodingDescription => 'Hardwarebeschleunigung verwenden, wenn verfügbar'; + @override String get hardwareDecodingDescription => 'Hardwarebeschleunigung verwenden, sofern verfügbar'; @override String get bufferSize => 'Puffergröße'; @override String bufferSizeMB({required Object size}) => '${size}MB'; @override String get subtitleStyling => 'Untertitel-Stil'; @@ -1138,9 +1143,10 @@ class _StringsUserStatusDe implements _StringsUserStatusEn { @override final _StringsDe _root; // ignore: unused_field // Translations - @override String get admin => 'Admin'; + @override String get admin => 'Eigentümer'; @override String get restricted => 'Eingeschränkt'; @override String get protected => 'Geschützt'; + @override String get current => 'AKTUELL'; } // Path: messages @@ -1163,8 +1169,8 @@ class _StringsMessagesDe implements _StringsMessagesEn { @override String get logsCopied => 'Protokolle in Zwischenablage kopiert'; @override String get noLogsAvailable => 'Keine Protokolle verfügbar'; @override String libraryScanning({required Object title}) => 'Scanne „${title}“...'; - @override String libraryScanStarted({required Object title}) => 'Bibliotheksscan gestartet für „${title}“'; - @override String libraryScanFailed({required Object error}) => 'Fehler beim Scannen der Bibliothek: ${error}'; + @override String libraryScanStarted({required Object title}) => 'Mediathekscan gestartet für „${title}“'; + @override String libraryScanFailed({required Object error}) => 'Fehler beim Scannen der Mediathek: ${error}'; @override String metadataRefreshing({required Object title}) => 'Metadaten werden aktualisiert für „${title}“...'; @override String metadataRefreshStarted({required Object title}) => 'Metadaten-Aktualisierung gestartet für „${title}“'; @override String metadataRefreshFailed({required Object error}) => 'Metadaten konnten nicht aktualisiert werden: ${error}'; @@ -1229,18 +1235,22 @@ class _StringsDiscoverDe implements _StringsDiscoverEn { @override String get switchServer => 'Server wechseln'; @override String get logout => 'Abmelden'; @override String get noContentAvailable => 'Kein Inhalt verfügbar'; - @override String get addMediaToLibraries => 'Medien zur Bibliothek hinzufügen'; + @override String get addMediaToLibraries => 'Medien zur Mediathek hinzufügen'; @override String get continueWatching => 'Weiterschauen'; @override String get recentlyAdded => 'Kürzlich hinzugefügt'; @override String get play => 'Abspielen'; @override String get resume => 'Fortsetzen'; - @override String playEpisode({required Object season, required Object episode}) => 'Spiele S${season}, E${episode}'; - @override String resumeEpisode({required Object season, required Object episode}) => 'Fortsetzen S${season}, E${episode}'; + @override String playEpisode({required Object season, required Object episode}) => 'S${season}, E${episode} - Abspielen'; + @override String resumeEpisode({required Object season, required Object episode}) => 'S${season}, E${episode} - Fortsetzen'; @override String get pause => 'Pause'; @override String get overview => 'Übersicht'; @override String get cast => 'Besetzung'; + @override String get seasons => 'Staffeln'; + @override String get studio => 'Studio'; + @override String get rating => 'Altersfreigabe'; + @override String get watched => 'Gesehen'; @override String episodeCount({required Object count}) => '${count} Episoden'; - @override String watchedProgress({required Object watched, required Object total}) => '${watched}/${total} gesehen'; + @override String watchedProgress({required Object watched, required Object total}) => '${watched} von ${total} gesehen'; @override String get movie => 'Film'; @override String get tvShow => 'Serie'; @override String minutesLeft({required Object minutes}) => '${minutes} Min übrig'; @@ -1274,11 +1284,11 @@ class _StringsLibrariesDe implements _StringsLibrariesEn { @override final _StringsDe _root; // ignore: unused_field // Translations - @override String get title => 'Bibliotheken'; - @override String get scanLibraryFiles => 'Bibliotheksdateien scannen'; - @override String get scanLibrary => 'Bibliothek scannen'; + @override String get title => 'Mediatheken'; + @override String get scanLibraryFiles => 'Mediatheksdateien scannen'; + @override String get scanLibrary => 'Mediathek scannen'; @override String get analyze => 'Analysieren'; - @override String get analyzeLibrary => 'Bibliothek analysieren'; + @override String get analyzeLibrary => 'Mediathek analysieren'; @override String get refreshMetadata => 'Metadaten aktualisieren'; @override String get emptyTrash => 'Papierkorb leeren'; @override String emptyingTrash({required Object title}) => 'Papierkorb für „${title}“ wird geleert...'; @@ -1286,24 +1296,24 @@ class _StringsLibrariesDe implements _StringsLibrariesEn { @override String failedToEmptyTrash({required Object error}) => 'Papierkorb konnte nicht geleert werden: ${error}'; @override String analyzing({required Object title}) => 'Analysiere „${title}“...'; @override String analysisStarted({required Object title}) => 'Analyse gestartet für „${title}“'; - @override String failedToAnalyze({required Object error}) => 'Analyse der Bibliothek fehlgeschlagen: ${error}'; - @override String get noLibrariesFound => 'Keine Bibliotheken gefunden'; - @override String get thisLibraryIsEmpty => 'Diese Bibliothek ist leer'; + @override String failedToAnalyze({required Object error}) => 'Analyse der Mediathek fehlgeschlagen: ${error}'; + @override String get noLibrariesFound => 'Keine Mediatheken gefunden'; + @override String get thisLibraryIsEmpty => 'Diese Mediathek ist leer'; @override String get all => 'Alle'; @override String get clearAll => 'Alle löschen'; @override String scanLibraryConfirm({required Object title}) => '„${title}“ wirklich scannen?'; @override String analyzeLibraryConfirm({required Object title}) => '„${title}“ wirklich analysieren?'; @override String refreshMetadataConfirm({required Object title}) => 'Metadaten für „${title}“ wirklich aktualisieren?'; @override String emptyTrashConfirm({required Object title}) => 'Papierkorb für „${title}“ wirklich leeren?'; - @override String get manageLibraries => 'Bibliotheken verwalten'; + @override String get manageLibraries => 'Mediatheken verwalten'; @override String get sort => 'Sortieren'; @override String get sortBy => 'Sortieren nach'; @override String get filters => 'Filter'; - @override String loadingLibraryWithCount({required Object count}) => 'Lade Bibliothek... (${count} Elemente geladen)'; + @override String loadingLibraryWithCount({required Object count}) => 'Lade Mediathek... (${count} Elemente geladen)'; @override String get confirmActionMessage => 'Aktion wirklich durchführen?'; - @override String get showLibrary => 'Bibliothek anzeigen'; - @override String get hideLibrary => 'Bibliothek ausblenden'; - @override String get libraryOptions => 'Bibliotheksoptionen'; + @override String get showLibrary => 'Mediathek anzeigen'; + @override String get hideLibrary => 'Mediathek ausblenden'; + @override String get libraryOptions => 'Mediatheksoptionen'; } // Path: about @@ -1393,7 +1403,7 @@ class _StringsNavigationDe implements _StringsNavigationEn { // Translations @override String get home => 'Start'; @override String get search => 'Suche'; - @override String get libraries => 'Bibliotheken'; + @override String get libraries => 'Mediatheken'; @override String get settings => 'Einstellungen'; } @@ -1404,40 +1414,40 @@ class _StringsPlaylistsDe implements _StringsPlaylistsEn { @override final _StringsDe _root; // ignore: unused_field // Translations - @override String get title => 'Playlists'; - @override String get noPlaylists => 'Keine Playlists gefunden'; - @override String get create => 'Playlist erstellen'; - @override String get newPlaylist => 'Neue Playlist'; - @override String get playlistName => 'Playlist-Name'; - @override String get enterPlaylistName => 'Playlist-Namen eingeben'; - @override String get edit => 'Playlist bearbeiten'; - @override String get delete => 'Playlist löschen'; - @override String get addTo => 'Zur Playlist hinzufügen'; + @override String get title => 'Wiedergabelisten'; + @override String get noPlaylists => 'Keine Wiedergabelisten gefunden'; + @override String get create => 'Wiedergabeliste erstellen'; + @override String get newPlaylist => 'Neue Wiedergabeliste'; + @override String get playlistName => 'Name der Wiedergabeliste'; + @override String get enterPlaylistName => 'Name der Wiedergabeliste eingeben'; + @override String get edit => 'Wiedergabeliste bearbeiten'; + @override String get delete => 'Wiedergabeliste löschen'; + @override String get addTo => 'Zur Wiedergabeliste hinzufügen'; @override String get addItems => 'Elemente hinzufügen'; - @override String get removeItem => 'Aus Playlist entfernen'; - @override String get clearPlaylist => 'Playlist leeren'; + @override String get removeItem => 'Aus Wiedergabeliste entfernen'; + @override String get clearPlaylist => 'Wiedergabeliste leeren'; @override String get playAll => 'Alle abspielen'; @override String get shuffle => 'Zufällig'; - @override String get smartPlaylist => 'Intelligente Playlist'; - @override String get regularPlaylist => 'Normale Playlist'; + @override String get smartPlaylist => 'Intelligente Wiedergabeliste'; + @override String get regularPlaylist => 'Normale Wiedergabeliste'; @override String itemCount({required Object count}) => '${count} Elemente'; @override String get oneItem => '1 Element'; - @override String get emptyPlaylist => 'Diese Playlist ist leer'; - @override String get deleteConfirm => 'Playlist löschen?'; - @override String deleteMessage({required Object name}) => 'Möchten Sie "${name}" wirklich löschen?'; - @override String get created => 'Playlist erstellt'; - @override String get updated => 'Playlist aktualisiert'; - @override String get deleted => 'Playlist gelöscht'; - @override String get itemAdded => 'Zur Playlist hinzugefügt'; - @override String get itemRemoved => 'Aus Playlist entfernt'; - @override String get selectPlaylist => 'Playlist auswählen'; - @override String get createNewPlaylist => 'Neue Playlist erstellen'; - @override String get errorCreating => 'Fehler beim Erstellen der Playlist'; - @override String get errorDeleting => 'Fehler beim Löschen der Playlist'; - @override String get errorLoading => 'Fehler beim Laden der Playlists'; - @override String get errorAdding => 'Fehler beim Hinzufügen zur Playlist'; - @override String get errorReordering => 'Fehler beim Neuordnen des Playlist-Elements'; - @override String get errorRemoving => 'Fehler beim Entfernen aus der Playlist'; + @override String get emptyPlaylist => 'Diese Wiedergabeliste ist leer'; + @override String get deleteConfirm => 'Wiedergabeliste löschen?'; + @override String deleteMessage({required Object name}) => 'Soll "${name}" wirklich gelöscht werden?'; + @override String get created => 'Wiedergabeliste erstellt'; + @override String get updated => 'Wiedergabeliste aktualisiert'; + @override String get deleted => 'Wiedergabeliste gelöscht'; + @override String get itemAdded => 'Zur Wiedergabeliste hinzugefügt'; + @override String get itemRemoved => 'Aus Wiedergabeliste entfernt'; + @override String get selectPlaylist => 'Wiedergabeliste auswählen'; + @override String get createNewPlaylist => 'Neue Wiedergabeliste erstellen'; + @override String get errorCreating => 'Wiedergabeliste konnte nicht erstellt werden'; + @override String get errorDeleting => 'Wiedergabeliste konnte nicht gelöscht werden'; + @override String get errorLoading => 'Wiedergabelisten konnten nicht geladen werden'; + @override String get errorAdding => 'Konnte nicht zur Wiedergabeliste hinzugefügt werden'; + @override String get errorReordering => 'Element der Wiedergabeliste konnte nicht neu geordnet werden'; + @override String get errorRemoving => 'Konnte nicht aus der Wiedergabeliste entfernt werden'; } // Path: @@ -1797,6 +1807,7 @@ class _StringsUserStatusIt implements _StringsUserStatusEn { @override String get admin => 'Admin'; @override String get restricted => 'Limitato'; @override String get protected => 'Protetto'; + @override String get current => 'ATTUALE'; } // Path: messages @@ -1895,6 +1906,10 @@ class _StringsDiscoverIt implements _StringsDiscoverEn { @override String get pause => 'Pausa'; @override String get overview => 'Panoramica'; @override String get cast => 'Cast'; + @override String get seasons => 'Stagioni'; + @override String get studio => 'Studio'; + @override String get rating => 'Classificazione'; + @override String get watched => 'Guardato'; @override String episodeCount({required Object count}) => '${count} episodi'; @override String watchedProgress({required Object watched, required Object total}) => '${watched}/${total} guardati'; @override String get movie => 'Film'; @@ -2453,6 +2468,7 @@ class _StringsUserStatusNl implements _StringsUserStatusEn { @override String get admin => 'Beheerder'; @override String get restricted => 'Beperkt'; @override String get protected => 'Beschermd'; + @override String get current => 'HUIDIG'; } // Path: messages @@ -2551,6 +2567,10 @@ class _StringsDiscoverNl implements _StringsDiscoverEn { @override String get pause => 'Pauzeren'; @override String get overview => 'Overzicht'; @override String get cast => 'Cast'; + @override String get seasons => 'Seizoenen'; + @override String get studio => 'Studio'; + @override String get rating => 'Leeftijd'; + @override String get watched => 'Bekeken'; @override String episodeCount({required Object count}) => '${count} afleveringen'; @override String watchedProgress({required Object watched, required Object total}) => '${watched}/${total} gekeken'; @override String get movie => 'Film'; @@ -3109,6 +3129,7 @@ class _StringsUserStatusSv implements _StringsUserStatusEn { @override String get admin => 'Admin'; @override String get restricted => 'Begränsad'; @override String get protected => 'Skyddad'; + @override String get current => 'NUVARANDE'; } // Path: messages @@ -3207,6 +3228,10 @@ class _StringsDiscoverSv implements _StringsDiscoverEn { @override String get pause => 'Pausa'; @override String get overview => 'Översikt'; @override String get cast => 'Rollbesättning'; + @override String get seasons => 'Säsonger'; + @override String get studio => 'Studio'; + @override String get rating => 'Åldersgräns'; + @override String get watched => 'Tittad'; @override String episodeCount({required Object count}) => '${count} avsnitt'; @override String watchedProgress({required Object watched, required Object total}) => '${watched}/${total} sedda'; @override String get movie => 'Film'; @@ -3765,6 +3790,7 @@ class _StringsUserStatusZh implements _StringsUserStatusEn { @override String get admin => '管理员'; @override String get restricted => '受限'; @override String get protected => '受保护'; + @override String get current => '当前'; } // Path: messages @@ -3863,6 +3889,10 @@ class _StringsDiscoverZh implements _StringsDiscoverEn { @override String get pause => '暂停'; @override String get overview => '概述'; @override String get cast => '演员表'; + @override String get seasons => '季数'; + @override String get studio => '制作公司'; + @override String get rating => '年龄分级'; + @override String get watched => '已观看'; @override String episodeCount({required Object count}) => '${count} 集'; @override String watchedProgress({required Object watched, required Object total}) => '已观看 ${watched}/${total} 集'; @override String get movie => '电影'; @@ -4249,6 +4279,7 @@ extension on Translations { case 'userStatus.admin': return 'Admin'; case 'userStatus.restricted': return 'Restricted'; case 'userStatus.protected': return 'Protected'; + case 'userStatus.current': return 'CURRENT'; case 'messages.markedAsWatched': return 'Marked as watched'; case 'messages.markedAsUnwatched': return 'Marked as unwatched'; case 'messages.removedFromContinueWatching': return 'Removed from Continue Watching'; @@ -4302,6 +4333,10 @@ extension on Translations { case 'discover.pause': return 'Pause'; case 'discover.overview': return 'Overview'; case 'discover.cast': return 'Cast'; + case 'discover.seasons': return 'Seasons'; + case 'discover.studio': return 'Studio'; + case 'discover.rating': return 'Rating'; + case 'discover.watched': return 'Watched'; case 'discover.episodeCount': return ({required Object count}) => '${count} episodes'; case 'discover.watchedProgress': return ({required Object watched, required Object total}) => '${watched}/${total} watched'; case 'discover.movie': return 'Movie'; @@ -4454,7 +4489,7 @@ extension on _StringsDe { case 'common.logout': return 'Abmelden'; case 'common.online': return 'Online'; case 'common.offline': return 'Offline'; - case 'common.owned': return 'Eigentum'; + case 'common.owned': return 'Besitzer'; case 'common.shared': return 'Geteilt'; case 'common.current': return 'AKTUELL'; case 'common.unknown': return 'Unbekannt'; @@ -4477,7 +4512,7 @@ extension on _StringsDe { case 'update.checkFailed': return 'Fehler bei der Updateprüfung'; case 'settings.title': return 'Einstellungen'; case 'settings.language': return 'Sprache'; - case 'settings.theme': return 'Theme'; + case 'settings.theme': return 'Design'; case 'settings.appearance': return 'Darstellung'; case 'settings.videoPlayback': return 'Videowiedergabe'; case 'settings.shufflePlay': return 'Zufallswiedergabe'; @@ -4493,7 +4528,7 @@ extension on _StringsDe { case 'settings.systemThemeDescription': return 'Systemeinstellungen folgen'; case 'settings.lightTheme': return 'Hell'; case 'settings.darkTheme': return 'Dunkel'; - case 'settings.libraryDensity': return 'Bibliotheksdichte'; + case 'settings.libraryDensity': return 'Mediathekdichte'; case 'settings.compact': return 'Kompakt'; case 'settings.compactDescription': return 'Kleinere Karten, mehr Elemente sichtbar'; case 'settings.normal': return 'Normal'; @@ -4508,7 +4543,7 @@ extension on _StringsDe { case 'settings.useSeasonPosters': return 'Staffelposter verwenden'; case 'settings.showHeroSection': return 'Hero-Bereich anzeigen'; case 'settings.hardwareDecoding': return 'Hardware-Decodierung'; - case 'settings.hardwareDecodingDescription': return 'Hardwarebeschleunigung verwenden, wenn verfügbar'; + case 'settings.hardwareDecodingDescription': return 'Hardwarebeschleunigung verwenden, sofern verfügbar'; case 'settings.bufferSize': return 'Puffergröße'; case 'settings.bufferSizeMB': return ({required Object size}) => '${size}MB'; case 'settings.subtitleStyling': return 'Untertitel-Stil'; @@ -4604,9 +4639,10 @@ extension on _StringsDe { case 'videoControls.stretch': return 'Strecken'; case 'videoControls.lockRotation': return 'Rotation sperren'; case 'videoControls.unlockRotation': return 'Rotation entsperren'; - case 'userStatus.admin': return 'Admin'; + case 'userStatus.admin': return 'Eigentümer'; case 'userStatus.restricted': return 'Eingeschränkt'; case 'userStatus.protected': return 'Geschützt'; + case 'userStatus.current': return 'AKTUELL'; case 'messages.markedAsWatched': return 'Als gesehen markiert'; case 'messages.markedAsUnwatched': return 'Als ungesehen markiert'; case 'messages.removedFromContinueWatching': return 'Aus ‚Weiterschauen‘ entfernt'; @@ -4620,8 +4656,8 @@ extension on _StringsDe { case 'messages.logsCopied': return 'Protokolle in Zwischenablage kopiert'; case 'messages.noLogsAvailable': return 'Keine Protokolle verfügbar'; case 'messages.libraryScanning': return ({required Object title}) => 'Scanne „${title}“...'; - case 'messages.libraryScanStarted': return ({required Object title}) => 'Bibliotheksscan gestartet für „${title}“'; - case 'messages.libraryScanFailed': return ({required Object error}) => 'Fehler beim Scannen der Bibliothek: ${error}'; + case 'messages.libraryScanStarted': return ({required Object title}) => 'Mediathekscan gestartet für „${title}“'; + case 'messages.libraryScanFailed': return ({required Object error}) => 'Fehler beim Scannen der Mediathek: ${error}'; case 'messages.metadataRefreshing': return ({required Object title}) => 'Metadaten werden aktualisiert für „${title}“...'; case 'messages.metadataRefreshStarted': return ({required Object title}) => 'Metadaten-Aktualisierung gestartet für „${title}“'; case 'messages.metadataRefreshFailed': return ({required Object error}) => 'Metadaten konnten nicht aktualisiert werden: ${error}'; @@ -4650,18 +4686,22 @@ extension on _StringsDe { case 'discover.switchServer': return 'Server wechseln'; case 'discover.logout': return 'Abmelden'; case 'discover.noContentAvailable': return 'Kein Inhalt verfügbar'; - case 'discover.addMediaToLibraries': return 'Medien zur Bibliothek hinzufügen'; + case 'discover.addMediaToLibraries': return 'Medien zur Mediathek hinzufügen'; case 'discover.continueWatching': return 'Weiterschauen'; case 'discover.recentlyAdded': return 'Kürzlich hinzugefügt'; case 'discover.play': return 'Abspielen'; case 'discover.resume': return 'Fortsetzen'; - case 'discover.playEpisode': return ({required Object season, required Object episode}) => 'Spiele S${season}, E${episode}'; - case 'discover.resumeEpisode': return ({required Object season, required Object episode}) => 'Fortsetzen S${season}, E${episode}'; + case 'discover.playEpisode': return ({required Object season, required Object episode}) => 'S${season}, E${episode} - Abspielen'; + case 'discover.resumeEpisode': return ({required Object season, required Object episode}) => 'S${season}, E${episode} - Fortsetzen'; case 'discover.pause': return 'Pause'; case 'discover.overview': return 'Übersicht'; case 'discover.cast': return 'Besetzung'; + case 'discover.seasons': return 'Staffeln'; + case 'discover.studio': return 'Studio'; + case 'discover.rating': return 'Altersfreigabe'; + case 'discover.watched': return 'Gesehen'; case 'discover.episodeCount': return ({required Object count}) => '${count} Episoden'; - case 'discover.watchedProgress': return ({required Object watched, required Object total}) => '${watched}/${total} gesehen'; + case 'discover.watchedProgress': return ({required Object watched, required Object total}) => '${watched} von ${total} gesehen'; case 'discover.movie': return 'Film'; case 'discover.tvShow': return 'Serie'; case 'discover.minutesLeft': return ({required Object minutes}) => '${minutes} Min übrig'; @@ -4677,11 +4717,11 @@ extension on _StringsDe { case 'errors.failedToVerifyToken': return ({required Object error}) => 'Token-Verifizierung fehlgeschlagen: ${error}'; case 'errors.failedToSwitchProfile': return ({required Object displayName}) => 'Profilwechsel zu ${displayName} fehlgeschlagen'; case 'errors.connectionFailedGeneric': return 'Verbindung fehlgeschlagen'; - case 'libraries.title': return 'Bibliotheken'; - case 'libraries.scanLibraryFiles': return 'Bibliotheksdateien scannen'; - case 'libraries.scanLibrary': return 'Bibliothek scannen'; + case 'libraries.title': return 'Mediatheken'; + case 'libraries.scanLibraryFiles': return 'Mediatheksdateien scannen'; + case 'libraries.scanLibrary': return 'Mediathek scannen'; case 'libraries.analyze': return 'Analysieren'; - case 'libraries.analyzeLibrary': return 'Bibliothek analysieren'; + case 'libraries.analyzeLibrary': return 'Mediathek analysieren'; case 'libraries.refreshMetadata': return 'Metadaten aktualisieren'; case 'libraries.emptyTrash': return 'Papierkorb leeren'; case 'libraries.emptyingTrash': return ({required Object title}) => 'Papierkorb für „${title}“ wird geleert...'; @@ -4689,24 +4729,24 @@ extension on _StringsDe { case 'libraries.failedToEmptyTrash': return ({required Object error}) => 'Papierkorb konnte nicht geleert werden: ${error}'; case 'libraries.analyzing': return ({required Object title}) => 'Analysiere „${title}“...'; case 'libraries.analysisStarted': return ({required Object title}) => 'Analyse gestartet für „${title}“'; - case 'libraries.failedToAnalyze': return ({required Object error}) => 'Analyse der Bibliothek fehlgeschlagen: ${error}'; - case 'libraries.noLibrariesFound': return 'Keine Bibliotheken gefunden'; - case 'libraries.thisLibraryIsEmpty': return 'Diese Bibliothek ist leer'; + case 'libraries.failedToAnalyze': return ({required Object error}) => 'Analyse der Mediathek fehlgeschlagen: ${error}'; + case 'libraries.noLibrariesFound': return 'Keine Mediatheken gefunden'; + case 'libraries.thisLibraryIsEmpty': return 'Diese Mediathek ist leer'; case 'libraries.all': return 'Alle'; case 'libraries.clearAll': return 'Alle löschen'; case 'libraries.scanLibraryConfirm': return ({required Object title}) => '„${title}“ wirklich scannen?'; case 'libraries.analyzeLibraryConfirm': return ({required Object title}) => '„${title}“ wirklich analysieren?'; case 'libraries.refreshMetadataConfirm': return ({required Object title}) => 'Metadaten für „${title}“ wirklich aktualisieren?'; case 'libraries.emptyTrashConfirm': return ({required Object title}) => 'Papierkorb für „${title}“ wirklich leeren?'; - case 'libraries.manageLibraries': return 'Bibliotheken verwalten'; + case 'libraries.manageLibraries': return 'Mediatheken verwalten'; case 'libraries.sort': return 'Sortieren'; case 'libraries.sortBy': return 'Sortieren nach'; case 'libraries.filters': return 'Filter'; - case 'libraries.loadingLibraryWithCount': return ({required Object count}) => 'Lade Bibliothek... (${count} Elemente geladen)'; + case 'libraries.loadingLibraryWithCount': return ({required Object count}) => 'Lade Mediathek... (${count} Elemente geladen)'; case 'libraries.confirmActionMessage': return 'Aktion wirklich durchführen?'; - case 'libraries.showLibrary': return 'Bibliothek anzeigen'; - case 'libraries.hideLibrary': return 'Bibliothek ausblenden'; - case 'libraries.libraryOptions': return 'Bibliotheksoptionen'; + case 'libraries.showLibrary': return 'Mediathek anzeigen'; + case 'libraries.hideLibrary': return 'Mediathek ausblenden'; + case 'libraries.libraryOptions': return 'Mediatheksoptionen'; case 'about.title': return 'Über'; case 'about.openSourceLicenses': return 'Open-Source-Lizenzen'; case 'about.versionLabel': return ({required Object version}) => 'Version ${version}'; @@ -4742,42 +4782,42 @@ extension on _StringsDe { case 'licenses.licensesCount': return ({required Object count}) => '${count} Lizenzen'; case 'navigation.home': return 'Start'; case 'navigation.search': return 'Suche'; - case 'navigation.libraries': return 'Bibliotheken'; + case 'navigation.libraries': return 'Mediatheken'; case 'navigation.settings': return 'Einstellungen'; - case 'playlists.title': return 'Playlists'; - case 'playlists.noPlaylists': return 'Keine Playlists gefunden'; - case 'playlists.create': return 'Playlist erstellen'; - case 'playlists.newPlaylist': return 'Neue Playlist'; - case 'playlists.playlistName': return 'Playlist-Name'; - case 'playlists.enterPlaylistName': return 'Playlist-Namen eingeben'; - case 'playlists.edit': return 'Playlist bearbeiten'; - case 'playlists.delete': return 'Playlist löschen'; - case 'playlists.addTo': return 'Zur Playlist hinzufügen'; + case 'playlists.title': return 'Wiedergabelisten'; + case 'playlists.noPlaylists': return 'Keine Wiedergabelisten gefunden'; + case 'playlists.create': return 'Wiedergabeliste erstellen'; + case 'playlists.newPlaylist': return 'Neue Wiedergabeliste'; + case 'playlists.playlistName': return 'Name der Wiedergabeliste'; + case 'playlists.enterPlaylistName': return 'Name der Wiedergabeliste eingeben'; + case 'playlists.edit': return 'Wiedergabeliste bearbeiten'; + case 'playlists.delete': return 'Wiedergabeliste löschen'; + case 'playlists.addTo': return 'Zur Wiedergabeliste hinzufügen'; case 'playlists.addItems': return 'Elemente hinzufügen'; - case 'playlists.removeItem': return 'Aus Playlist entfernen'; - case 'playlists.clearPlaylist': return 'Playlist leeren'; + case 'playlists.removeItem': return 'Aus Wiedergabeliste entfernen'; + case 'playlists.clearPlaylist': return 'Wiedergabeliste leeren'; case 'playlists.playAll': return 'Alle abspielen'; case 'playlists.shuffle': return 'Zufällig'; - case 'playlists.smartPlaylist': return 'Intelligente Playlist'; - case 'playlists.regularPlaylist': return 'Normale Playlist'; + case 'playlists.smartPlaylist': return 'Intelligente Wiedergabeliste'; + case 'playlists.regularPlaylist': return 'Normale Wiedergabeliste'; case 'playlists.itemCount': return ({required Object count}) => '${count} Elemente'; case 'playlists.oneItem': return '1 Element'; - case 'playlists.emptyPlaylist': return 'Diese Playlist ist leer'; - case 'playlists.deleteConfirm': return 'Playlist löschen?'; - case 'playlists.deleteMessage': return ({required Object name}) => 'Möchten Sie "${name}" wirklich löschen?'; - case 'playlists.created': return 'Playlist erstellt'; - case 'playlists.updated': return 'Playlist aktualisiert'; - case 'playlists.deleted': return 'Playlist gelöscht'; - case 'playlists.itemAdded': return 'Zur Playlist hinzugefügt'; - case 'playlists.itemRemoved': return 'Aus Playlist entfernt'; - case 'playlists.selectPlaylist': return 'Playlist auswählen'; - case 'playlists.createNewPlaylist': return 'Neue Playlist erstellen'; - case 'playlists.errorCreating': return 'Fehler beim Erstellen der Playlist'; - case 'playlists.errorDeleting': return 'Fehler beim Löschen der Playlist'; - case 'playlists.errorLoading': return 'Fehler beim Laden der Playlists'; - case 'playlists.errorAdding': return 'Fehler beim Hinzufügen zur Playlist'; - case 'playlists.errorReordering': return 'Fehler beim Neuordnen des Playlist-Elements'; - case 'playlists.errorRemoving': return 'Fehler beim Entfernen aus der Playlist'; + case 'playlists.emptyPlaylist': return 'Diese Wiedergabeliste ist leer'; + case 'playlists.deleteConfirm': return 'Wiedergabeliste löschen?'; + case 'playlists.deleteMessage': return ({required Object name}) => 'Soll "${name}" wirklich gelöscht werden?'; + case 'playlists.created': return 'Wiedergabeliste erstellt'; + case 'playlists.updated': return 'Wiedergabeliste aktualisiert'; + case 'playlists.deleted': return 'Wiedergabeliste gelöscht'; + case 'playlists.itemAdded': return 'Zur Wiedergabeliste hinzugefügt'; + case 'playlists.itemRemoved': return 'Aus Wiedergabeliste entfernt'; + case 'playlists.selectPlaylist': return 'Wiedergabeliste auswählen'; + case 'playlists.createNewPlaylist': return 'Neue Wiedergabeliste erstellen'; + case 'playlists.errorCreating': return 'Wiedergabeliste konnte nicht erstellt werden'; + case 'playlists.errorDeleting': return 'Wiedergabeliste konnte nicht gelöscht werden'; + case 'playlists.errorLoading': return 'Wiedergabelisten konnten nicht geladen werden'; + case 'playlists.errorAdding': return 'Konnte nicht zur Wiedergabeliste hinzugefügt werden'; + case 'playlists.errorReordering': return 'Element der Wiedergabeliste konnte nicht neu geordnet werden'; + case 'playlists.errorRemoving': return 'Konnte nicht aus der Wiedergabeliste entfernt werden'; default: return null; } } @@ -4965,6 +5005,7 @@ extension on _StringsIt { case 'userStatus.admin': return 'Admin'; case 'userStatus.restricted': return 'Limitato'; case 'userStatus.protected': return 'Protetto'; + case 'userStatus.current': return 'ATTUALE'; case 'messages.markedAsWatched': return 'Segna come visto'; case 'messages.markedAsUnwatched': return 'Segna come non visto'; case 'messages.removedFromContinueWatching': return 'Rimosso da Continua a guardare'; @@ -5018,6 +5059,10 @@ extension on _StringsIt { case 'discover.pause': return 'Pausa'; case 'discover.overview': return 'Panoramica'; case 'discover.cast': return 'Cast'; + case 'discover.seasons': return 'Stagioni'; + case 'discover.studio': return 'Studio'; + case 'discover.rating': return 'Classificazione'; + case 'discover.watched': return 'Guardato'; case 'discover.episodeCount': return ({required Object count}) => '${count} episodi'; case 'discover.watchedProgress': return ({required Object watched, required Object total}) => '${watched}/${total} guardati'; case 'discover.movie': return 'Film'; @@ -5323,6 +5368,7 @@ extension on _StringsNl { case 'userStatus.admin': return 'Beheerder'; case 'userStatus.restricted': return 'Beperkt'; case 'userStatus.protected': return 'Beschermd'; + case 'userStatus.current': return 'HUIDIG'; case 'messages.markedAsWatched': return 'Gemarkeerd als gekeken'; case 'messages.markedAsUnwatched': return 'Gemarkeerd als ongekeken'; case 'messages.removedFromContinueWatching': return 'Verwijderd uit Doorgaan met kijken'; @@ -5376,6 +5422,10 @@ extension on _StringsNl { case 'discover.pause': return 'Pauzeren'; case 'discover.overview': return 'Overzicht'; case 'discover.cast': return 'Cast'; + case 'discover.seasons': return 'Seizoenen'; + case 'discover.studio': return 'Studio'; + case 'discover.rating': return 'Leeftijd'; + case 'discover.watched': return 'Bekeken'; case 'discover.episodeCount': return ({required Object count}) => '${count} afleveringen'; case 'discover.watchedProgress': return ({required Object watched, required Object total}) => '${watched}/${total} gekeken'; case 'discover.movie': return 'Film'; @@ -5681,6 +5731,7 @@ extension on _StringsSv { case 'userStatus.admin': return 'Admin'; case 'userStatus.restricted': return 'Begränsad'; case 'userStatus.protected': return 'Skyddad'; + case 'userStatus.current': return 'NUVARANDE'; case 'messages.markedAsWatched': return 'Markerad som sedd'; case 'messages.markedAsUnwatched': return 'Markerad som osedd'; case 'messages.removedFromContinueWatching': return 'Borttagen från Fortsätt titta'; @@ -5734,6 +5785,10 @@ extension on _StringsSv { case 'discover.pause': return 'Pausa'; case 'discover.overview': return 'Översikt'; case 'discover.cast': return 'Rollbesättning'; + case 'discover.seasons': return 'Säsonger'; + case 'discover.studio': return 'Studio'; + case 'discover.rating': return 'Åldersgräns'; + case 'discover.watched': return 'Tittad'; case 'discover.episodeCount': return ({required Object count}) => '${count} avsnitt'; case 'discover.watchedProgress': return ({required Object watched, required Object total}) => '${watched}/${total} sedda'; case 'discover.movie': return 'Film'; @@ -6039,6 +6094,7 @@ extension on _StringsZh { case 'userStatus.admin': return '管理员'; case 'userStatus.restricted': return '受限'; case 'userStatus.protected': return '受保护'; + case 'userStatus.current': return '当前'; case 'messages.markedAsWatched': return '已标记为已观看'; case 'messages.markedAsUnwatched': return '已标记为未观看'; case 'messages.removedFromContinueWatching': return '已从继续观看中移除'; @@ -6092,6 +6148,10 @@ extension on _StringsZh { case 'discover.pause': return '暂停'; case 'discover.overview': return '概述'; case 'discover.cast': return '演员表'; + case 'discover.seasons': return '季数'; + case 'discover.studio': return '制作公司'; + case 'discover.rating': return '年龄分级'; + case 'discover.watched': return '已观看'; case 'discover.episodeCount': return ({required Object count}) => '${count} 集'; case 'discover.watchedProgress': return ({required Object watched, required Object total}) => '已观看 ${watched}/${total} 集'; case 'discover.movie': return '电影'; diff --git a/lib/i18n/strings.i18n.json b/lib/i18n/strings.i18n.json index 9712e83b..e57bc0fe 100644 --- a/lib/i18n/strings.i18n.json +++ b/lib/i18n/strings.i18n.json @@ -204,7 +204,8 @@ "userStatus": { "admin": "Admin", "restricted": "Restricted", - "protected": "Protected" + "protected": "Protected", + "current": "CURRENT" }, "messages": { "markedAsWatched": "Marked as watched", @@ -268,6 +269,10 @@ "pause": "Pause", "overview": "Overview", "cast": "Cast", + "seasons": "Seasons", + "studio": "Studio", + "rating": "Rating", + "watched": "Watched", "episodeCount": "${count} episodes", "watchedProgress": "${watched}/${total} watched", "movie": "Movie", diff --git a/lib/i18n/strings_de.i18n.json b/lib/i18n/strings_de.i18n.json index fa5c662c..e78c5411 100644 --- a/lib/i18n/strings_de.i18n.json +++ b/lib/i18n/strings_de.i18n.json @@ -30,7 +30,7 @@ "logout": "Abmelden", "online": "Online", "offline": "Offline", - "owned": "Eigentum", + "owned": "Besitzer", "shared": "Geteilt", "current": "AKTUELL", "unknown": "Unbekannt", @@ -59,7 +59,7 @@ "settings": { "title": "Einstellungen", "language": "Sprache", - "theme": "Theme", + "theme": "Design", "appearance": "Darstellung", "videoPlayback": "Videowiedergabe", "shufflePlay": "Zufallswiedergabe", @@ -75,7 +75,7 @@ "systemThemeDescription": "Systemeinstellungen folgen", "lightTheme": "Hell", "darkTheme": "Dunkel", - "libraryDensity": "Bibliotheksdichte", + "libraryDensity": "Mediathekdichte", "compact": "Kompakt", "compactDescription": "Kleinere Karten, mehr Elemente sichtbar", "normal": "Normal", @@ -90,7 +90,7 @@ "useSeasonPosters": "Staffelposter verwenden", "showHeroSection": "Hero-Bereich anzeigen", "hardwareDecoding": "Hardware-Decodierung", - "hardwareDecodingDescription": "Hardwarebeschleunigung verwenden, wenn verfügbar", + "hardwareDecodingDescription": "Hardwarebeschleunigung verwenden, sofern verfügbar", "bufferSize": "Puffergröße", "bufferSizeMB": "${size}MB", "subtitleStyling": "Untertitel-Stil", @@ -202,9 +202,10 @@ "unlockRotation": "Rotation entsperren" }, "userStatus": { - "admin": "Admin", + "admin": "Eigentümer", "restricted": "Eingeschränkt", - "protected": "Geschützt" + "protected": "Geschützt", + "current": "AKTUELL" }, "messages": { "markedAsWatched": "Als gesehen markiert", @@ -220,8 +221,8 @@ "logsCopied": "Protokolle in Zwischenablage kopiert", "noLogsAvailable": "Keine Protokolle verfügbar", "libraryScanning": "Scanne „${title}“...", - "libraryScanStarted": "Bibliotheksscan gestartet für „${title}“", - "libraryScanFailed": "Fehler beim Scannen der Bibliothek: ${error}", + "libraryScanStarted": "Mediathekscan gestartet für „${title}“", + "libraryScanFailed": "Fehler beim Scannen der Mediathek: ${error}", "metadataRefreshing": "Metadaten werden aktualisiert für „${title}“...", "metadataRefreshStarted": "Metadaten-Aktualisierung gestartet für „${title}“", "metadataRefreshFailed": "Metadaten konnten nicht aktualisiert werden: ${error}", @@ -258,18 +259,22 @@ "switchServer": "Server wechseln", "logout": "Abmelden", "noContentAvailable": "Kein Inhalt verfügbar", - "addMediaToLibraries": "Medien zur Bibliothek hinzufügen", + "addMediaToLibraries": "Medien zur Mediathek hinzufügen", "continueWatching": "Weiterschauen", "recentlyAdded": "Kürzlich hinzugefügt", "play": "Abspielen", "resume": "Fortsetzen", - "playEpisode": "Spiele S${season}, E${episode}", - "resumeEpisode": "Fortsetzen S${season}, E${episode}", + "playEpisode": "S${season}, E${episode} - Abspielen", + "resumeEpisode": "S${season}, E${episode} - Fortsetzen", "pause": "Pause", "overview": "Übersicht", "cast": "Besetzung", + "seasons": "Staffeln", + "studio": "Studio", + "rating": "Altersfreigabe", + "watched": "Gesehen", "episodeCount": "${count} Episoden", - "watchedProgress": "${watched}/${total} gesehen", + "watchedProgress": "${watched} von ${total} gesehen", "movie": "Film", "tvShow": "Serie", "minutesLeft": "${minutes} Min übrig" @@ -289,11 +294,11 @@ "connectionFailedGeneric": "Verbindung fehlgeschlagen" }, "libraries": { - "title": "Bibliotheken", - "scanLibraryFiles": "Bibliotheksdateien scannen", - "scanLibrary": "Bibliothek scannen", + "title": "Mediatheken", + "scanLibraryFiles": "Mediatheksdateien scannen", + "scanLibrary": "Mediathek scannen", "analyze": "Analysieren", - "analyzeLibrary": "Bibliothek analysieren", + "analyzeLibrary": "Mediathek analysieren", "refreshMetadata": "Metadaten aktualisieren", "emptyTrash": "Papierkorb leeren", "emptyingTrash": "Papierkorb für „${title}“ wird geleert...", @@ -301,24 +306,24 @@ "failedToEmptyTrash": "Papierkorb konnte nicht geleert werden: ${error}", "analyzing": "Analysiere „${title}“...", "analysisStarted": "Analyse gestartet für „${title}“", - "failedToAnalyze": "Analyse der Bibliothek fehlgeschlagen: ${error}", - "noLibrariesFound": "Keine Bibliotheken gefunden", - "thisLibraryIsEmpty": "Diese Bibliothek ist leer", + "failedToAnalyze": "Analyse der Mediathek fehlgeschlagen: ${error}", + "noLibrariesFound": "Keine Mediatheken gefunden", + "thisLibraryIsEmpty": "Diese Mediathek ist leer", "all": "Alle", "clearAll": "Alle löschen", "scanLibraryConfirm": "„${title}“ wirklich scannen?", "analyzeLibraryConfirm": "„${title}“ wirklich analysieren?", "refreshMetadataConfirm": "Metadaten für „${title}“ wirklich aktualisieren?", "emptyTrashConfirm": "Papierkorb für „${title}“ wirklich leeren?", - "manageLibraries": "Bibliotheken verwalten", + "manageLibraries": "Mediatheken verwalten", "sort": "Sortieren", "sortBy": "Sortieren nach", "filters": "Filter", - "loadingLibraryWithCount": "Lade Bibliothek... (${count} Elemente geladen)", + "loadingLibraryWithCount": "Lade Mediathek... (${count} Elemente geladen)", "confirmActionMessage": "Aktion wirklich durchführen?", - "showLibrary": "Bibliothek anzeigen", - "hideLibrary": "Bibliothek ausblenden", - "libraryOptions": "Bibliotheksoptionen" + "showLibrary": "Mediathek anzeigen", + "hideLibrary": "Mediathek ausblenden", + "libraryOptions": "Mediatheksoptionen" }, "about": { "title": "Über", @@ -366,43 +371,43 @@ "navigation": { "home": "Start", "search": "Suche", - "libraries": "Bibliotheken", + "libraries": "Mediatheken", "settings": "Einstellungen" }, "playlists": { - "title": "Playlists", - "noPlaylists": "Keine Playlists gefunden", - "create": "Playlist erstellen", - "newPlaylist": "Neue Playlist", - "playlistName": "Playlist-Name", - "enterPlaylistName": "Playlist-Namen eingeben", - "edit": "Playlist bearbeiten", - "delete": "Playlist löschen", - "addTo": "Zur Playlist hinzufügen", + "title": "Wiedergabelisten", + "noPlaylists": "Keine Wiedergabelisten gefunden", + "create": "Wiedergabeliste erstellen", + "newPlaylist": "Neue Wiedergabeliste", + "playlistName": "Name der Wiedergabeliste", + "enterPlaylistName": "Name der Wiedergabeliste eingeben", + "edit": "Wiedergabeliste bearbeiten", + "delete": "Wiedergabeliste löschen", + "addTo": "Zur Wiedergabeliste hinzufügen", "addItems": "Elemente hinzufügen", - "removeItem": "Aus Playlist entfernen", - "clearPlaylist": "Playlist leeren", + "removeItem": "Aus Wiedergabeliste entfernen", + "clearPlaylist": "Wiedergabeliste leeren", "playAll": "Alle abspielen", "shuffle": "Zufällig", - "smartPlaylist": "Intelligente Playlist", - "regularPlaylist": "Normale Playlist", + "smartPlaylist": "Intelligente Wiedergabeliste", + "regularPlaylist": "Normale Wiedergabeliste", "itemCount": "${count} Elemente", "oneItem": "1 Element", - "emptyPlaylist": "Diese Playlist ist leer", - "deleteConfirm": "Playlist löschen?", - "deleteMessage": "Möchten Sie \"${name}\" wirklich löschen?", - "created": "Playlist erstellt", - "updated": "Playlist aktualisiert", - "deleted": "Playlist gelöscht", - "itemAdded": "Zur Playlist hinzugefügt", - "itemRemoved": "Aus Playlist entfernt", - "selectPlaylist": "Playlist auswählen", - "createNewPlaylist": "Neue Playlist erstellen", - "errorCreating": "Fehler beim Erstellen der Playlist", - "errorDeleting": "Fehler beim Löschen der Playlist", - "errorLoading": "Fehler beim Laden der Playlists", - "errorAdding": "Fehler beim Hinzufügen zur Playlist", - "errorReordering": "Fehler beim Neuordnen des Playlist-Elements", - "errorRemoving": "Fehler beim Entfernen aus der Playlist" + "emptyPlaylist": "Diese Wiedergabeliste ist leer", + "deleteConfirm": "Wiedergabeliste löschen?", + "deleteMessage": "Soll \"${name}\" wirklich gelöscht werden?", + "created": "Wiedergabeliste erstellt", + "updated": "Wiedergabeliste aktualisiert", + "deleted": "Wiedergabeliste gelöscht", + "itemAdded": "Zur Wiedergabeliste hinzugefügt", + "itemRemoved": "Aus Wiedergabeliste entfernt", + "selectPlaylist": "Wiedergabeliste auswählen", + "createNewPlaylist": "Neue Wiedergabeliste erstellen", + "errorCreating": "Wiedergabeliste konnte nicht erstellt werden", + "errorDeleting": "Wiedergabeliste konnte nicht gelöscht werden", + "errorLoading": "Wiedergabelisten konnten nicht geladen werden", + "errorAdding": "Konnte nicht zur Wiedergabeliste hinzugefügt werden", + "errorReordering": "Element der Wiedergabeliste konnte nicht neu geordnet werden", + "errorRemoving": "Konnte nicht aus der Wiedergabeliste entfernt werden" } } diff --git a/lib/i18n/strings_it.i18n.json b/lib/i18n/strings_it.i18n.json index 8672f423..04a05e14 100644 --- a/lib/i18n/strings_it.i18n.json +++ b/lib/i18n/strings_it.i18n.json @@ -204,7 +204,8 @@ "userStatus": { "admin": "Admin", "restricted": "Limitato", - "protected": "Protetto" + "protected": "Protetto", + "current": "ATTUALE" }, "messages": { "markedAsWatched": "Segna come visto", @@ -268,6 +269,10 @@ "pause": "Pausa", "overview": "Panoramica", "cast": "Cast", + "seasons": "Stagioni", + "studio": "Studio", + "rating": "Classificazione", + "watched": "Guardato", "episodeCount": "${count} episodi", "watchedProgress": "${watched}/${total} guardati", "movie": "Film", diff --git a/lib/i18n/strings_nl.i18n.json b/lib/i18n/strings_nl.i18n.json index 04640b3f..bced748e 100644 --- a/lib/i18n/strings_nl.i18n.json +++ b/lib/i18n/strings_nl.i18n.json @@ -204,7 +204,8 @@ "userStatus": { "admin": "Beheerder", "restricted": "Beperkt", - "protected": "Beschermd" + "protected": "Beschermd", + "current": "HUIDIG" }, "messages": { "markedAsWatched": "Gemarkeerd als gekeken", @@ -268,6 +269,10 @@ "pause": "Pauzeren", "overview": "Overzicht", "cast": "Cast", + "seasons": "Seizoenen", + "studio": "Studio", + "rating": "Leeftijd", + "watched": "Bekeken", "episodeCount": "${count} afleveringen", "watchedProgress": "${watched}/${total} gekeken", "movie": "Film", diff --git a/lib/i18n/strings_sv.i18n.json b/lib/i18n/strings_sv.i18n.json index 54f72356..b97094d9 100644 --- a/lib/i18n/strings_sv.i18n.json +++ b/lib/i18n/strings_sv.i18n.json @@ -204,7 +204,8 @@ "userStatus": { "admin": "Admin", "restricted": "Begränsad", - "protected": "Skyddad" + "protected": "Skyddad", + "current": "NUVARANDE" }, "messages": { "markedAsWatched": "Markerad som sedd", @@ -268,6 +269,10 @@ "pause": "Pausa", "overview": "Översikt", "cast": "Rollbesättning", + "seasons": "Säsonger", + "studio": "Studio", + "rating": "Åldersgräns", + "watched": "Tittad", "episodeCount": "${count} avsnitt", "watchedProgress": "${watched}/${total} sedda", "movie": "Film", diff --git a/lib/i18n/strings_zh.i18n.json b/lib/i18n/strings_zh.i18n.json index 705ae2ee..8daa0990 100644 --- a/lib/i18n/strings_zh.i18n.json +++ b/lib/i18n/strings_zh.i18n.json @@ -204,7 +204,8 @@ "userStatus": { "admin": "管理员", "restricted": "受限", - "protected": "受保护" + "protected": "受保护", + "current": "当前" }, "messages": { "markedAsWatched": "已标记为已观看", @@ -268,6 +269,10 @@ "pause": "暂停", "overview": "概述", "cast": "演员表", + "seasons": "季数", + "studio": "制作公司", + "rating": "年龄分级", + "watched": "已观看", "episodeCount": "${count} 集", "watchedProgress": "已观看 ${watched}/${total} 集", "movie": "电影", diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 9831062e..0b1afff6 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -768,7 +768,7 @@ class _MediaDetailScreenState extends State { // Seasons (for TV shows) if (isShow) ...[ Text( - 'Seasons', + t.discover.seasons, style: Theme.of(context).textTheme.titleLarge?.copyWith( fontWeight: FontWeight.bold, ), @@ -930,12 +930,12 @@ class _MediaDetailScreenState extends State { // Additional info if (metadata.studio != null) ...[ - _buildInfoRow('Studio', metadata.studio!), + _buildInfoRow(t.discover.studio, metadata.studio!), const SizedBox(height: 12), ], if (metadata.contentRating != null) ...[ _buildInfoRow( - 'Rating', + t.discover.rating, formatContentRating(metadata.contentRating!), ), const SizedBox(height: 12), diff --git a/lib/screens/season_detail_screen.dart b/lib/screens/season_detail_screen.dart index 24995170..137816d0 100644 --- a/lib/screens/season_detail_screen.dart +++ b/lib/screens/season_detail_screen.dart @@ -350,7 +350,7 @@ class _SeasonDetailScreenState extends State ), ), Text( - 'Watched ✓', + '${t.discover.watched} ✓', style: Theme.of(context).textTheme.bodySmall ?.copyWith( color: tokens(context).textMuted, diff --git a/lib/widgets/profile_list_tile.dart b/lib/widgets/profile_list_tile.dart index ca806763..9712653f 100644 --- a/lib/widgets/profile_list_tile.dart +++ b/lib/widgets/profile_list_tile.dart @@ -1,7 +1,10 @@ import 'package:flutter/material.dart'; +import '../i18n/strings.g.dart'; import '../models/plex_home_user.dart'; import 'user_avatar_widget.dart'; +enum UserAttribute { admin, restricted, protected } + class ProfileListTile extends StatelessWidget { final PlexHomeUser user; final VoidCallback onTap; @@ -34,7 +37,7 @@ class ProfileListTile extends StatelessWidget { borderRadius: BorderRadius.circular(12), ), child: Text( - 'CURRENT', + t.userStatus.current, style: TextStyle( fontSize: 10, color: theme.colorScheme.onPrimary, @@ -55,21 +58,21 @@ class ProfileListTile extends StatelessWidget { List _buildUserAttributes(ThemeData theme) { final attributes = []; - final labels = []; + final List userAttributes = []; if (user.isAdminUser) { - labels.add('Admin'); + userAttributes.add(UserAttribute.admin); } if (user.isRestrictedUser && !user.isAdminUser) { - labels.add('Restricted'); + userAttributes.add(UserAttribute.restricted); } if (user.requiresPassword) { - labels.add('Protected'); + userAttributes.add(UserAttribute.protected); } - for (int i = 0; i < labels.length; i++) { + for (int i = 0; i < userAttributes.length; i++) { if (i > 0) { attributes.addAll([ const SizedBox(width: 8), @@ -84,12 +87,14 @@ class ProfileListTile extends StatelessWidget { ]); } + final attribute = userAttributes[i]; + attributes.add( Text( - labels[i], + _getAttributeLabel(attribute), style: TextStyle( fontSize: 12, - color: _getAttributeColor(labels[i], theme), + color: _getAttributeColor(attribute, theme), fontWeight: FontWeight.w500, ), ), @@ -99,16 +104,25 @@ class ProfileListTile extends StatelessWidget { return attributes; } - Color _getAttributeColor(String attribute, ThemeData theme) { + String _getAttributeLabel(UserAttribute attribute) { switch (attribute) { - case 'Admin': + case UserAttribute.admin: + return t.userStatus.admin; + case UserAttribute.restricted: + return t.userStatus.restricted; + case UserAttribute.protected: + return t.userStatus.protected; + } + } + + Color _getAttributeColor(UserAttribute attribute, ThemeData theme) { + switch (attribute) { + case UserAttribute.admin: return theme.colorScheme.primary; - case 'Restricted': + case UserAttribute.restricted: return theme.colorScheme.warning ?? Colors.orange; - case 'Protected': + case UserAttribute.protected: return theme.colorScheme.secondary; - default: - return theme.colorScheme.onSurface; } } } From be69d16948e386ae21cb2fe4319340dc10c49feb Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 16 Nov 2025 03:08:56 +0100 Subject: [PATCH 19/29] fix(android): working mpv builds --- lib/screens/video_player_screen.dart | 2 +- pubspec.lock | 32 ++++++++++++++-------------- pubspec.yaml | 12 +++++------ 3 files changed, 23 insertions(+), 23 deletions(-) diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 0eb7a339..53fe04ad 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -181,7 +181,7 @@ class VideoPlayerScreenState extends State 'sub-border-color': settingsService.getSubtitleBorderColor(), 'sub-back-color': '#${(settingsService.getSubtitleBackgroundOpacity() * 255 / 100).toInt().toRadixString(16).padLeft(2, '0').toUpperCase()}${settingsService.getSubtitleBackgroundColor().replaceFirst('#', '')}', - 'sub-ass-override': 'yes', + 'sub-ass-override': 'no', }, ), ); diff --git a/pubspec.lock b/pubspec.lock index 7d033286..f5885bec 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -556,8 +556,8 @@ packages: dependency: "direct main" description: path: media_kit - ref: de5c4e23722999b63ac9fbd648019a2288028721 - resolved-ref: de5c4e23722999b63ac9fbd648019a2288028721 + ref: de9eca361f90d989390cd8d840a1019576ed6bb5 + resolved-ref: de9eca361f90d989390cd8d840a1019576ed6bb5 url: "https://github.com/edde746/media-kit" source: git version: "1.2.1" @@ -565,8 +565,8 @@ packages: dependency: "direct overridden" description: path: "libs/android/media_kit_libs_android_video" - ref: de5c4e23722999b63ac9fbd648019a2288028721 - resolved-ref: de5c4e23722999b63ac9fbd648019a2288028721 + ref: de9eca361f90d989390cd8d840a1019576ed6bb5 + resolved-ref: de9eca361f90d989390cd8d840a1019576ed6bb5 url: "https://github.com/edde746/media-kit" source: git version: "1.3.9" @@ -574,8 +574,8 @@ packages: dependency: "direct overridden" description: path: "libs/ios/media_kit_libs_ios_video" - ref: de5c4e23722999b63ac9fbd648019a2288028721 - resolved-ref: de5c4e23722999b63ac9fbd648019a2288028721 + ref: de9eca361f90d989390cd8d840a1019576ed6bb5 + resolved-ref: de9eca361f90d989390cd8d840a1019576ed6bb5 url: "https://github.com/edde746/media-kit" source: git version: "1.1.4" @@ -583,8 +583,8 @@ packages: dependency: transitive description: path: "libs/linux/media_kit_libs_linux" - ref: de5c4e23722999b63ac9fbd648019a2288028721 - resolved-ref: de5c4e23722999b63ac9fbd648019a2288028721 + ref: de9eca361f90d989390cd8d840a1019576ed6bb5 + resolved-ref: de9eca361f90d989390cd8d840a1019576ed6bb5 url: "https://github.com/edde746/media-kit" source: git version: "1.2.1" @@ -592,8 +592,8 @@ packages: dependency: "direct overridden" description: path: "libs/macos/media_kit_libs_macos_video" - ref: de5c4e23722999b63ac9fbd648019a2288028721 - resolved-ref: de5c4e23722999b63ac9fbd648019a2288028721 + ref: de9eca361f90d989390cd8d840a1019576ed6bb5 + resolved-ref: de9eca361f90d989390cd8d840a1019576ed6bb5 url: "https://github.com/edde746/media-kit" source: git version: "1.1.5" @@ -601,8 +601,8 @@ packages: dependency: "direct main" description: path: "libs/universal/media_kit_libs_video" - ref: de5c4e23722999b63ac9fbd648019a2288028721 - resolved-ref: de5c4e23722999b63ac9fbd648019a2288028721 + ref: de9eca361f90d989390cd8d840a1019576ed6bb5 + resolved-ref: de9eca361f90d989390cd8d840a1019576ed6bb5 url: "https://github.com/edde746/media-kit" source: git version: "1.0.7" @@ -610,8 +610,8 @@ packages: dependency: transitive description: path: "libs/windows/media_kit_libs_windows_video" - ref: de5c4e23722999b63ac9fbd648019a2288028721 - resolved-ref: de5c4e23722999b63ac9fbd648019a2288028721 + ref: de9eca361f90d989390cd8d840a1019576ed6bb5 + resolved-ref: de9eca361f90d989390cd8d840a1019576ed6bb5 url: "https://github.com/edde746/media-kit" source: git version: "1.0.12" @@ -619,8 +619,8 @@ packages: dependency: "direct main" description: path: media_kit_video - ref: de5c4e23722999b63ac9fbd648019a2288028721 - resolved-ref: de5c4e23722999b63ac9fbd648019a2288028721 + ref: de9eca361f90d989390cd8d840a1019576ed6bb5 + resolved-ref: de9eca361f90d989390cd8d840a1019576ed6bb5 url: "https://github.com/edde746/media-kit" source: git version: "1.3.1" diff --git a/pubspec.yaml b/pubspec.yaml index 1de465c4..30d4b9a9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,32 +38,32 @@ dependency_overrides: media_kit: git: url: https://github.com/edde746/media-kit - ref: de5c4e23722999b63ac9fbd648019a2288028721 + ref: de9eca361f90d989390cd8d840a1019576ed6bb5 path: media_kit media_kit_video: git: url: https://github.com/edde746/media-kit - ref: de5c4e23722999b63ac9fbd648019a2288028721 + ref: de9eca361f90d989390cd8d840a1019576ed6bb5 path: media_kit_video media_kit_libs_video: git: url: https://github.com/edde746/media-kit - ref: de5c4e23722999b63ac9fbd648019a2288028721 + ref: de9eca361f90d989390cd8d840a1019576ed6bb5 path: libs/universal/media_kit_libs_video media_kit_libs_macos_video: git: url: https://github.com/edde746/media-kit - ref: de5c4e23722999b63ac9fbd648019a2288028721 + ref: de9eca361f90d989390cd8d840a1019576ed6bb5 path: libs/macos/media_kit_libs_macos_video media_kit_libs_ios_video: git: url: https://github.com/edde746/media-kit - ref: de5c4e23722999b63ac9fbd648019a2288028721 + ref: de9eca361f90d989390cd8d840a1019576ed6bb5 path: libs/ios/media_kit_libs_ios_video media_kit_libs_android_video: git: url: https://github.com/edde746/media-kit - ref: de5c4e23722999b63ac9fbd648019a2288028721 + ref: de9eca361f90d989390cd8d840a1019576ed6bb5 path: libs/android/media_kit_libs_android_video dev_dependencies: From d10b882516da31edeb7765d8f064631e40f38a4e Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 16 Nov 2025 03:53:38 +0100 Subject: [PATCH 20/29] fix(android): add missing crop effect --- pubspec.lock | 32 ++++++++++++++++---------------- pubspec.yaml | 12 ++++++------ 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index f5885bec..e818babd 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -556,8 +556,8 @@ packages: dependency: "direct main" description: path: media_kit - ref: de9eca361f90d989390cd8d840a1019576ed6bb5 - resolved-ref: de9eca361f90d989390cd8d840a1019576ed6bb5 + ref: "6077829fa6705f30f85c1393a36008d5f66c06f4" + resolved-ref: "6077829fa6705f30f85c1393a36008d5f66c06f4" url: "https://github.com/edde746/media-kit" source: git version: "1.2.1" @@ -565,8 +565,8 @@ packages: dependency: "direct overridden" description: path: "libs/android/media_kit_libs_android_video" - ref: de9eca361f90d989390cd8d840a1019576ed6bb5 - resolved-ref: de9eca361f90d989390cd8d840a1019576ed6bb5 + ref: "6077829fa6705f30f85c1393a36008d5f66c06f4" + resolved-ref: "6077829fa6705f30f85c1393a36008d5f66c06f4" url: "https://github.com/edde746/media-kit" source: git version: "1.3.9" @@ -574,8 +574,8 @@ packages: dependency: "direct overridden" description: path: "libs/ios/media_kit_libs_ios_video" - ref: de9eca361f90d989390cd8d840a1019576ed6bb5 - resolved-ref: de9eca361f90d989390cd8d840a1019576ed6bb5 + ref: "6077829fa6705f30f85c1393a36008d5f66c06f4" + resolved-ref: "6077829fa6705f30f85c1393a36008d5f66c06f4" url: "https://github.com/edde746/media-kit" source: git version: "1.1.4" @@ -583,8 +583,8 @@ packages: dependency: transitive description: path: "libs/linux/media_kit_libs_linux" - ref: de9eca361f90d989390cd8d840a1019576ed6bb5 - resolved-ref: de9eca361f90d989390cd8d840a1019576ed6bb5 + ref: "6077829fa6705f30f85c1393a36008d5f66c06f4" + resolved-ref: "6077829fa6705f30f85c1393a36008d5f66c06f4" url: "https://github.com/edde746/media-kit" source: git version: "1.2.1" @@ -592,8 +592,8 @@ packages: dependency: "direct overridden" description: path: "libs/macos/media_kit_libs_macos_video" - ref: de9eca361f90d989390cd8d840a1019576ed6bb5 - resolved-ref: de9eca361f90d989390cd8d840a1019576ed6bb5 + ref: "6077829fa6705f30f85c1393a36008d5f66c06f4" + resolved-ref: "6077829fa6705f30f85c1393a36008d5f66c06f4" url: "https://github.com/edde746/media-kit" source: git version: "1.1.5" @@ -601,8 +601,8 @@ packages: dependency: "direct main" description: path: "libs/universal/media_kit_libs_video" - ref: de9eca361f90d989390cd8d840a1019576ed6bb5 - resolved-ref: de9eca361f90d989390cd8d840a1019576ed6bb5 + ref: "6077829fa6705f30f85c1393a36008d5f66c06f4" + resolved-ref: "6077829fa6705f30f85c1393a36008d5f66c06f4" url: "https://github.com/edde746/media-kit" source: git version: "1.0.7" @@ -610,8 +610,8 @@ packages: dependency: transitive description: path: "libs/windows/media_kit_libs_windows_video" - ref: de9eca361f90d989390cd8d840a1019576ed6bb5 - resolved-ref: de9eca361f90d989390cd8d840a1019576ed6bb5 + ref: "6077829fa6705f30f85c1393a36008d5f66c06f4" + resolved-ref: "6077829fa6705f30f85c1393a36008d5f66c06f4" url: "https://github.com/edde746/media-kit" source: git version: "1.0.12" @@ -619,8 +619,8 @@ packages: dependency: "direct main" description: path: media_kit_video - ref: de9eca361f90d989390cd8d840a1019576ed6bb5 - resolved-ref: de9eca361f90d989390cd8d840a1019576ed6bb5 + ref: "6077829fa6705f30f85c1393a36008d5f66c06f4" + resolved-ref: "6077829fa6705f30f85c1393a36008d5f66c06f4" url: "https://github.com/edde746/media-kit" source: git version: "1.3.1" diff --git a/pubspec.yaml b/pubspec.yaml index 30d4b9a9..d6ae92be 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,32 +38,32 @@ dependency_overrides: media_kit: git: url: https://github.com/edde746/media-kit - ref: de9eca361f90d989390cd8d840a1019576ed6bb5 + ref: 6077829fa6705f30f85c1393a36008d5f66c06f4 path: media_kit media_kit_video: git: url: https://github.com/edde746/media-kit - ref: de9eca361f90d989390cd8d840a1019576ed6bb5 + ref: 6077829fa6705f30f85c1393a36008d5f66c06f4 path: media_kit_video media_kit_libs_video: git: url: https://github.com/edde746/media-kit - ref: de9eca361f90d989390cd8d840a1019576ed6bb5 + ref: 6077829fa6705f30f85c1393a36008d5f66c06f4 path: libs/universal/media_kit_libs_video media_kit_libs_macos_video: git: url: https://github.com/edde746/media-kit - ref: de9eca361f90d989390cd8d840a1019576ed6bb5 + ref: 6077829fa6705f30f85c1393a36008d5f66c06f4 path: libs/macos/media_kit_libs_macos_video media_kit_libs_ios_video: git: url: https://github.com/edde746/media-kit - ref: de9eca361f90d989390cd8d840a1019576ed6bb5 + ref: 6077829fa6705f30f85c1393a36008d5f66c06f4 path: libs/ios/media_kit_libs_ios_video media_kit_libs_android_video: git: url: https://github.com/edde746/media-kit - ref: de9eca361f90d989390cd8d840a1019576ed6bb5 + ref: 6077829fa6705f30f85c1393a36008d5f66c06f4 path: libs/android/media_kit_libs_android_video dev_dependencies: From 860b8bba49323462659597c1941b49f3d94d4e30 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 13 Nov 2025 20:04:47 +0000 Subject: [PATCH 21/29] Add comprehensive CI sanity checks and documentation Co-authored-by: Doezer <11655673+Doezer@users.noreply.github.com> --- .github/workflows/ci.yml | 122 +++++++++++++++++++++++++++++++++++++++ CONTRIBUTING.md | 22 ++++++- README.md | 19 ++++++ analysis_options.yaml | 5 ++ 4 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..7d2f441d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,122 @@ +name: CI - Sanity Checks + +on: + push: + branches: + - main + pull_request: + branches: + - main + workflow_dispatch: + +jobs: + analyze: + name: Code Analysis + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: "stable" + cache: true + + - name: Cache Pub dependencies + uses: actions/cache@v4 + with: + path: | + ~/.pub-cache + key: ${{ runner.os }}-pub-v2-${{ hashFiles('**/pubspec.lock') }} + restore-keys: | + ${{ runner.os }}-pub-v2- + + - name: Install dependencies + run: | + + flutter pub get + + - name: Verify formatting + run: | + # Find all Dart files excluding generated files + find lib $([ -d test ] && echo test) -name "*.dart" ! -name "*.g.dart" ! -name "*.freezed.dart" -type f 2>/dev/null | while IFS= read -r file; do + files_found=true + break + done + if [ "$files_found" != "true" ]; then + echo "No Dart files found to format" + exit 0 + fi + find lib $([ -d test ] && echo test) -name "*.dart" ! -name "*.g.dart" ! -name "*.freezed.dart" -type f 2>/dev/null -print0 | xargs -0 dart format --output=none --set-exit-if-changed + - name: Analyze code + run: flutter analyze + + test: + name: Unit Tests + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: "stable" + cache: true + + - name: Cache Pub dependencies + uses: actions/cache@v4 + with: + path: | + ~/.pub-cache + key: ${{ runner.os }}-pub-v2-${{ hashFiles('**/pubspec.lock') }} + restore-keys: | + ${{ runner.os }}-pub-v2- + + - name: Install dependencies + run: | + flutter clean + flutter pub get + + - name: Run tests + run: | + if [ -d "test" ] && [ "$(find test -name '*_test.dart' | wc -l)" -gt 0 ]; then + flutter test + else + echo "No tests found, skipping test execution" + fi + + dependency-check: + name: Dependency Validation + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: "stable" + cache: true + + - name: Cache Pub dependencies + uses: actions/cache@v4 + with: + path: | + ~/.pub-cache + key: ${{ runner.os }}-pub-v2-${{ hashFiles('**/pubspec.lock') }} + restore-keys: | + ${{ runner.os }}-pub-v2- + + - name: Verify dependencies + run: | + flutter clean + flutter pub get + flutter pub outdated diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1742ed9d..9ce9e5e5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,9 +10,29 @@ ## Development - Follow Dart/Flutter conventions -- Run `flutter analyze` before submitting +- Run `dart format .` to format your code (note: generated files like `*.g.dart` are excluded from CI checks) +- Run `flutter analyze` before submitting to check for issues +- Run `flutter test` if tests are available - Test your changes thoroughly +### Code Quality Checks + +The project includes automated CI checks that run on all pull requests: + +1. **Code Formatting**: Ensures code follows Dart formatting standards + - Run locally: `dart format .` to format all files + - Note: CI only checks non-generated files (excludes `.g.dart`, `.freezed.dart`) + - Generated files are reformatted automatically by build tools + +2. **Static Analysis**: Checks for code issues and potential bugs + - Run locally: `flutter analyze` + - Note: CI excludes generated files from analysis (configured in `analysis_options.yaml`) + +3. **Tests**: Runs unit and widget tests (when available) + - Run locally: `flutter test` + +All these checks must pass before your changes can be merged. + ## Internationalization (i18n) This project uses `slang` for internationalization with JSON files. diff --git a/README.md b/README.md index e2092494..b74aed11 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,25 @@ flutter run ## Development +### Code Quality + +Before submitting changes, ensure your code passes all quality checks: + +```bash +# Format code (including generated files) +dart format . + +# Analyze code for issues +flutter analyze + +# Run tests (if available) +flutter test +``` + +**Note**: CI checks exclude generated files (`.g.dart`, `.freezed.dart`) from formatting and analysis checks. You can run `dart format .` locally to format everything, but only your hand-written code will be validated in CI. + +These checks are automatically run in CI for all pull requests. + ### Code Generation The project uses code generation for JSON serialization. After modifying model classes, run: diff --git a/analysis_options.yaml b/analysis_options.yaml index f9b30346..2e3c70e5 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1 +1,6 @@ include: package:flutter_lints/flutter.yaml + +analyzer: + exclude: + - "**/*.g.dart" + - "**/*.freezed.dart" From 4f5531b67c8e6bdf16fc171f9439c9781295bd2d Mon Sep 17 00:00:00 2001 From: Doezer Date: Sun, 16 Nov 2025 08:45:08 +0100 Subject: [PATCH 22/29] update the CI code analysis to not fail on 'info' and warnings --- .github/workflows/ci.yml | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7d2f441d..56a3c512 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,7 +52,21 @@ jobs: fi find lib $([ -d test ] && echo test) -name "*.dart" ! -name "*.g.dart" ! -name "*.freezed.dart" -type f 2>/dev/null -print0 | xargs -0 dart format --output=none --set-exit-if-changed - name: Analyze code - run: flutter analyze + run: | + # Run flutter analyze and filter out info-level warnings + # Only fail on errors and warnings, not on info messages + flutter analyze 2>&1 | tee analyze_output.txt + # Check if there are any errors (not just info) + if grep -q "error •" analyze_output.txt; then + echo "❌ Analysis failed with errors" + exit 1 + elif grep -q "warning •" analyze_output.txt; then + echo "⚠️ Analysis completed with warnings" + exit 1 + else + echo "✅ Analysis passed (info messages are allowed)" + exit 0 + fi test: name: Unit Tests From 080907c8139141cf4cbbeeab8493f07088514006 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 16 Nov 2025 11:04:39 +0100 Subject: [PATCH 23/29] fix: don't fallback on HTTP errors --- lib/network/endpoint_failover_interceptor.dart | 5 ----- 1 file changed, 5 deletions(-) diff --git a/lib/network/endpoint_failover_interceptor.dart b/lib/network/endpoint_failover_interceptor.dart index e0ecd011..5a4c621c 100644 --- a/lib/network/endpoint_failover_interceptor.dart +++ b/lib/network/endpoint_failover_interceptor.dart @@ -138,11 +138,6 @@ class EndpointFailoverInterceptor extends Interceptor { return true; } - if (error.type == DioExceptionType.badResponse) { - final statusCode = error.response?.statusCode ?? 0; - return statusCode >= 500; - } - return false; } From 0c23de9edecd19778c7cc818a94d22769e3ee325 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 16 Nov 2025 20:53:59 +0100 Subject: [PATCH 24/29] feat: library overhaul add collections, library specific home screen, and more --- lib/client/plex_client.dart | 342 +++++- lib/constants/layout_constants.dart | 98 ++ lib/i18n/strings.g.dart | 772 +++++++++++- lib/i18n/strings.i18n.json | 61 +- lib/i18n/strings_de.i18n.json | 63 +- lib/i18n/strings_it.i18n.json | 63 +- lib/i18n/strings_nl.i18n.json | 63 +- lib/i18n/strings_sv.i18n.json | 63 +- lib/i18n/strings_zh.i18n.json | 63 +- lib/models/play_queue_response.dart | 77 ++ lib/models/play_queue_response.g.dart | 28 + lib/models/plex_metadata.dart | 12 + lib/models/plex_metadata.g.dart | 6 + lib/providers/playback_state_provider.dart | 269 ++++- lib/screens/collection_detail_screen.dart | 314 +++++ lib/screens/discover_screen.dart | 46 +- lib/screens/libraries_screen.dart | 1046 +++++------------ .../library_tabs/library_browse_tab.dart | 602 ++++++++++ .../library_tabs/library_collections_tab.dart | 195 +++ .../library_tabs/library_playlists_tab.dart | 199 ++++ .../library_tabs/library_recommended_tab.dart | 162 +++ lib/screens/media_detail_screen.dart | 2 +- lib/screens/playlist_detail_screen.dart | 93 +- lib/screens/season_detail_screen.dart | 2 +- lib/screens/video_player_screen.dart | 23 +- lib/services/storage_service.dart | 62 +- .../collection_playlist_play_helper.dart | 116 ++ lib/utils/grid_size_calculator.dart | 53 + lib/utils/library_refresh_notifier.dart | 38 + lib/widgets/empty_state_widget.dart | 64 + lib/widgets/error_state_widget.dart | 61 + lib/widgets/filters_bottom_sheet.dart | 320 +++++ lib/widgets/folder_tree_item.dart | 124 ++ lib/widgets/folder_tree_view.dart | 268 +++++ lib/widgets/hub_section.dart | 136 +++ lib/widgets/media_card.dart | 354 ++++-- lib/widgets/media_context_menu.dart | 701 ++++++++++- lib/widgets/sort_bottom_sheet.dart | 11 +- 38 files changed, 5891 insertions(+), 1081 deletions(-) create mode 100644 lib/constants/layout_constants.dart create mode 100644 lib/models/play_queue_response.dart create mode 100644 lib/models/play_queue_response.g.dart create mode 100644 lib/screens/collection_detail_screen.dart create mode 100644 lib/screens/library_tabs/library_browse_tab.dart create mode 100644 lib/screens/library_tabs/library_collections_tab.dart create mode 100644 lib/screens/library_tabs/library_playlists_tab.dart create mode 100644 lib/screens/library_tabs/library_recommended_tab.dart create mode 100644 lib/utils/collection_playlist_play_helper.dart create mode 100644 lib/utils/grid_size_calculator.dart create mode 100644 lib/utils/library_refresh_notifier.dart create mode 100644 lib/widgets/empty_state_widget.dart create mode 100644 lib/widgets/error_state_widget.dart create mode 100644 lib/widgets/filters_bottom_sheet.dart create mode 100644 lib/widgets/folder_tree_item.dart create mode 100644 lib/widgets/folder_tree_view.dart create mode 100644 lib/widgets/hub_section.dart diff --git a/lib/client/plex_client.dart b/lib/client/plex_client.dart index 0b31a98a..3bc42b2e 100644 --- a/lib/client/plex_client.dart +++ b/lib/client/plex_client.dart @@ -13,6 +13,7 @@ import '../models/plex_metadata.dart'; import '../models/plex_playlist.dart'; import '../models/plex_sort.dart'; import '../models/plex_video_playback_data.dart'; +import '../models/play_queue_response.dart'; import '../network/endpoint_failover_interceptor.dart'; import '../utils/app_logger.dart'; import '../utils/log_redaction_manager.dart'; @@ -546,7 +547,10 @@ class PlexClient { // Remove leading slash if present final path = thumbPath.startsWith('/') ? thumbPath.substring(1) : thumbPath; - return '${config.baseUrl}/$path?X-Plex-Token=${config.token}'; + // Check if path already has query parameters + final separator = path.contains('?') ? '&' : '?'; + + return '${config.baseUrl}/$path${separator}X-Plex-Token=${config.token}'; } /// Get video URL for direct playback @@ -1484,6 +1488,342 @@ class PlexClient { } } + // ============================================================================ + // Collection Methods + // ============================================================================ + + /// Get all collections for a library section + /// Returns collections as PlexMetadata objects with type="collection" + Future> getLibraryCollections(String sectionId) async { + try { + final response = await _dio.get( + '/library/sections/$sectionId/collections', + queryParameters: { + 'includeGuids': 1, + }, + ); + final allItems = _extractMetadataList(response); + + // Collections should have type="collection" + return allItems.where((item) { + return item.type.toLowerCase() == 'collection'; + }).toList(); + } catch (e) { + appLogger.e('Failed to get library collections: $e'); + return []; + } + } + + /// Get items in a collection + /// Returns the list of metadata items in the collection + Future> getCollectionItems(String collectionId) async { + try { + final response = await _dio.get('/library/collections/$collectionId/children'); + return _extractMetadataList(response); + } catch (e) { + appLogger.e('Failed to get collection items: $e'); + return []; + } + } + + /// Delete a collection + /// Deletes a library collection from the server + Future deleteCollection(String sectionId, String collectionId) async { + try { + appLogger.d('Deleting collection: sectionId=$sectionId, collectionId=$collectionId'); + final response = await _dio.delete('/library/collections/$collectionId'); + appLogger.d('Delete collection response: ${response.statusCode}'); + return true; + } catch (e) { + appLogger.e('Failed to delete collection', error: e); + return false; + } + } + + /// Create a new collection + /// Creates a new collection and optionally adds items to it + /// Returns the created collection ID or null if failed + Future createCollection({ + required String sectionId, + required String title, + required String uri, + int? type, + }) async { + try { + appLogger.d('Creating collection: sectionId=$sectionId, title=$title, type=$type'); + final response = await _dio.post( + '/library/collections', + queryParameters: { + if (type != null) 'type': type, + 'title': title, + 'smart': 0, + 'sectionId': sectionId, + 'uri': uri, + }, + ); + appLogger.d('Create collection response: ${response.statusCode}'); + + // Extract the collection ID from the response + // The response should contain the created collection metadata + if (response.data != null && response.data['MediaContainer'] != null) { + final metadata = response.data['MediaContainer']['Metadata']; + if (metadata != null && metadata.isNotEmpty) { + final collectionId = metadata[0]['ratingKey']?.toString(); + appLogger.d('Created collection with ID: $collectionId'); + return collectionId; + } + } + + return null; + } catch (e) { + appLogger.e('Failed to create collection', error: e); + return null; + } + } + + /// Add items to an existing collection + /// Adds one or more items (specified by URI) to an existing collection + Future addToCollection({ + required String collectionId, + required String uri, + }) async { + try { + appLogger.d('Adding items to collection: collectionId=$collectionId'); + final response = await _dio.put( + '/library/collections/$collectionId/items', + queryParameters: { + 'uri': uri, + }, + ); + appLogger.d('Add to collection response: ${response.statusCode}'); + return true; + } catch (e) { + appLogger.e('Failed to add items to collection', error: e); + return false; + } + } + + /// Remove an item from a collection + /// Removes a single item from an existing collection + Future removeFromCollection({ + required String collectionId, + required String itemId, + }) async { + try { + appLogger.d('Removing item from collection: collectionId=$collectionId, itemId=$itemId'); + final response = await _dio.delete( + '/library/collections/$collectionId/items/$itemId', + ); + appLogger.d('Remove from collection response: ${response.statusCode}'); + return true; + } catch (e) { + appLogger.e('Failed to remove item from collection', error: e); + return false; + } + } + + // ============================================================================ + // Play Queue Methods + // ============================================================================ + + /// Create a new play queue + /// Either uri or playlistID must be specified + Future createPlayQueue({ + String? uri, + int? playlistID, + required String type, + String? key, + int shuffle = 0, + int repeat = 0, + int continuous = 0, + }) async { + try { + final queryParams = { + 'type': type, + 'shuffle': shuffle, + 'repeat': repeat, + 'continuous': continuous, + }; + + if (uri != null) { + queryParams['uri'] = uri; + } + if (playlistID != null) { + queryParams['playlistID'] = playlistID; + } + if (key != null) { + queryParams['key'] = key; + } + + final response = await _dio.post( + '/playQueues', + queryParameters: queryParams, + ); + + return PlayQueueResponse.fromJson(response.data); + } catch (e) { + appLogger.e('Failed to create play queue', error: e); + return null; + } + } + + /// Get a play queue with optional windowing + /// Can request a window of items around a specific item + Future getPlayQueue( + int playQueueId, { + String? center, + int window = 50, + int includeBefore = 1, + int includeAfter = 1, + }) async { + try { + final queryParams = { + 'window': window, + 'includeBefore': includeBefore, + 'includeAfter': includeAfter, + }; + + if (center != null) { + queryParams['center'] = center; + } + + final response = await _dio.get( + '/playQueues/$playQueueId', + queryParameters: queryParams, + ); + + return PlayQueueResponse.fromJson(response.data); + } catch (e) { + appLogger.e('Failed to get play queue: $e'); + return null; + } + } + + /// Shuffle a play queue + /// The currently selected item is maintained + Future shufflePlayQueue(int playQueueId) async { + try { + final response = await _dio.put('/playQueues/$playQueueId/shuffle'); + return PlayQueueResponse.fromJson(response.data); + } catch (e) { + appLogger.e('Failed to shuffle play queue: $e'); + return null; + } + } + + /// Clear all items from a play queue + Future clearPlayQueue(int playQueueId) async { + try { + await _dio.delete('/playQueues/$playQueueId/items'); + return true; + } catch (e) { + appLogger.e('Failed to clear play queue: $e'); + return false; + } + } + + /// Extract both Metadata and Directory entries from response + /// Folders can come back as either type + List _extractMetadataAndDirectories(Response response) { + final List items = []; + final container = _getMediaContainer(response); + + if (container != null) { + // Extract Metadata entries - try full parsing first + if (container['Metadata'] != null) { + for (final json in container['Metadata'] as List) { + try { + // Try to parse with full PlexMetadata.fromJson first + items.add(PlexMetadata.fromJson(json)); + } catch (e) { + // If full parsing fails, use minimal safe parsing + appLogger.d('Using minimal parsing for metadata item: $e'); + try { + items.add(PlexMetadata( + ratingKey: json['key'] ?? json['ratingKey'] ?? '', + key: json['key'] ?? '', + type: json['type'] ?? 'folder', + title: json['title'] ?? 'Untitled', + thumb: json['thumb'], + art: json['art'], + year: json['year'], + )); + } catch (e2) { + appLogger.e('Failed to parse metadata item: $e2'); + } + } + } + } + + // Extract Directory entries (folders) + if (container['Directory'] != null) { + for (final json in container['Directory'] as List) { + try { + // Try to parse as PlexMetadata first + items.add(PlexMetadata.fromJson(json)); + } catch (e) { + // If that fails, use minimal folder representation + try { + items.add(PlexMetadata( + ratingKey: json['key'] ?? json['ratingKey'] ?? '', + key: json['key'] ?? '', + type: json['type'] ?? 'folder', + title: json['title'] ?? 'Untitled', + thumb: json['thumb'], + art: json['art'], + )); + } catch (e2) { + appLogger.e('Failed to parse directory item: $e2'); + } + } + } + } + } + + return items; + } + + /// Get root folders for a library section + /// Returns the top-level folder structure for filesystem-based browsing + Future> getLibraryFolders(String sectionId) async { + try { + final response = await _dio.get( + '/library/sections/$sectionId/folder', + queryParameters: { + 'includeCollections': 0, + }, + ); + return _extractMetadataAndDirectories(response); + } catch (e) { + appLogger.e('Failed to get library folders: $e'); + return []; + } + } + + /// Get children of a specific folder + /// Returns files and subfolders within the given folder + Future> getFolderChildren(String folderKey) async { + try { + final response = await _dio.get(folderKey); + return _extractMetadataAndDirectories(response); + } catch (e) { + appLogger.e('Failed to get folder children: $e'); + return []; + } + } + + /// Get library-specific playlists + /// Filters playlists by checking if they contain items from the specified library + /// This is a client-side filter since the API doesn't support sectionId for playlists + Future> getLibraryPlaylists({ + required String sectionId, + String playlistType = 'video', + }) async { + // For now, return all video playlists + // Future enhancement: filter by checking playlist items' library + return getPlaylists(playlistType: playlistType); + } + // ============================================================================ // Library Management Methods // ============================================================================ diff --git a/lib/constants/layout_constants.dart b/lib/constants/layout_constants.dart new file mode 100644 index 00000000..68c62510 --- /dev/null +++ b/lib/constants/layout_constants.dart @@ -0,0 +1,98 @@ +/// Layout and sizing constants used throughout the application + +/// Screen width breakpoints for responsive design +class ScreenBreakpoints { + /// Breakpoint for tablet devices (600px) + static const double tablet = 600; + + /// Breakpoint for desktop devices (1200px) + static const double desktop = 1200; + + /// Breakpoint for large desktop devices (1600px) + static const double largeDesktop = 1600; +} + +/// Pagination constants +class PaginationConstants { + /// Default page size for library items + static const int defaultPageSize = 1000; + + /// Page size for search results + static const int searchPageSize = 50; +} + +/// Grid layout constants +class GridLayoutConstants { + /// Maximum cross-axis extent for grid items in comfortable density mode + static const double comfortableDesktop = 280; + static const double comfortableTablet = 240; + static const double comfortableMobile = 200; + + /// Maximum cross-axis extent for grid items in compact density mode + static const double compactDesktop = 200; + static const double compactTablet = 170; + static const double compactMobile = 140; + + /// Maximum cross-axis extent for grid items in normal density mode + static const double normalDesktop = 240; + static const double normalTablet = 200; + static const double normalMobile = 170; + + /// Default aspect ratio for media cards (poster) + static const double posterAspectRatio = 2 / 3.3; + + /// Grid spacing + static const double crossAxisSpacing = 0; + static const double mainAxisSpacing = 0; +} + +/// Padding and spacing constants +class SpacingConstants { + /// Extra small spacing (4px) + static const double xs = 4; + + /// Small spacing (8px) + static const double sm = 8; + + /// Medium spacing (12px) + static const double md = 12; + + /// Large spacing (16px) + static const double lg = 16; + + /// Extra large spacing (24px) + static const double xl = 24; + + /// Double extra large spacing (32px) + static const double xxl = 32; +} + +/// Icon size constants +class IconSizeConstants { + /// Small icon size (16px) + static const double sm = 16; + + /// Medium icon size (24px) + static const double md = 24; + + /// Large icon size (32px) + static const double lg = 32; + + /// Extra large icon size (48px) + static const double xl = 48; +} + +/// Border radius constants +class BorderRadiusConstants { + /// Small border radius (4px) + static const double sm = 4; + + /// Medium border radius (8px) + static const double md = 8; + + /// Large border radius (12px) + static const double lg = 12; + + /// Extra large border radius (16px) + static const double xl = 16; +} diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index d95497f8..67236e1d 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 6 -/// Strings: 2130 (355 per locale) +/// Strings: 2424 (404 per locale) /// -/// Built on 2025-11-15 at 11:48 UTC +/// Built on 2025-11-16 at 19:45 UTC // coverage:ignore-file // ignore_for_file: type=lint @@ -179,6 +179,7 @@ class Translations implements BaseTranslations { late final _StringsLogsEn logs = _StringsLogsEn._(_root); late final _StringsLicensesEn licenses = _StringsLicensesEn._(_root); late final _StringsNavigationEn navigation = _StringsNavigationEn._(_root); + late final _StringsCollectionsEn collections = _StringsCollectionsEn._(_root); late final _StringsPlaylistsEn playlists = _StringsPlaylistsEn._(_root); } @@ -241,6 +242,9 @@ class _StringsCommonEn { String get yes => 'Yes'; String get no => 'No'; String get server => 'Server'; + String get delete => 'Delete'; + String get shuffle => 'Shuffle'; + String get addTo => 'Add to...'; } // Path: screens @@ -521,6 +525,10 @@ class _StringsMessagesEn { String get noResultsFound => 'No results found'; String sleepTimerSet({required Object label}) => 'Sleep timer set for ${label}'; String failedToSwitchProfile({required Object displayName}) => 'Failed to switch to ${displayName}'; + String get noItemsAvailable => 'No items available'; + String get failedToCreatePlayQueue => 'Failed to create play queue'; + String get failedToCreatePlayQueueNoItems => 'Failed to create play queue - no items'; + String failedPlayback({required Object action, required Object error}) => 'Failed to ${action}: ${error}'; } // Path: profile @@ -653,6 +661,15 @@ class _StringsLibrariesEn { String get showLibrary => 'Show library'; String get hideLibrary => 'Hide library'; String get libraryOptions => 'Library options'; + String get content => 'library content'; + String get selectLibrary => 'Select library'; + String filtersWithCount({required Object count}) => 'Filters (${count})'; + String get noRecommendations => 'No recommendations available'; + String get noCollections => 'No collections in this library'; + String get noFoldersFound => 'No folders found'; + String get folders => 'folders'; + late final _StringsLibrariesTabsEn tabs = _StringsLibrariesTabsEn._(_root); + late final _StringsLibrariesGroupingsEn groupings = _StringsLibrariesGroupingsEn._(_root); } // Path: about @@ -746,6 +763,39 @@ class _StringsNavigationEn { String get settings => 'Settings'; } +// Path: collections +class _StringsCollectionsEn { + _StringsCollectionsEn._(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + String get title => 'Collections'; + String get collection => 'Collection'; + String get empty => 'Collection is empty'; + String get noItems => 'No items in this collection'; + String get unknownLibrarySection => 'Cannot delete: Unknown library section'; + String get deleteCollection => 'Delete Collection'; + String deleteConfirm({required Object title}) => 'Are you sure you want to delete "${title}"? This action cannot be undone.'; + String get deleted => 'Collection deleted'; + String get deleteFailed => 'Failed to delete collection'; + String deleteFailedWithError({required Object error}) => 'Failed to delete collection: ${error}'; + String failedToLoadItems({required Object error}) => 'Failed to load collection items: ${error}'; + String get addTo => 'Add to collection'; + String get selectCollection => 'Select Collection'; + String get createNewCollection => 'Create New Collection'; + String get collectionName => 'Collection Name'; + String get enterCollectionName => 'Enter collection name'; + String get addedToCollection => 'Added to collection'; + String get errorAddingToCollection => 'Failed to add to collection'; + String get created => 'Collection created'; + String get removeFromCollection => 'Remove from collection'; + String removeFromCollectionConfirm({required Object title}) => 'Remove "${title}" from this collection?'; + String get removedFromCollection => 'Removed from collection'; + String get removeFromCollectionFailed => 'Failed to remove from collection'; + String removeFromCollectionError({required Object error}) => 'Error removing from collection: ${error}'; +} + // Path: playlists class _StringsPlaylistsEn { _StringsPlaylistsEn._(this._root); @@ -754,6 +804,7 @@ class _StringsPlaylistsEn { // Translations String get title => 'Playlists'; + String get playlist => 'Playlist'; String get noPlaylists => 'No playlists found'; String get create => 'Create Playlist'; String get newPlaylist => 'New Playlist'; @@ -789,6 +840,34 @@ class _StringsPlaylistsEn { String get errorRemoving => 'Failed to remove from playlist'; } +// Path: libraries.tabs +class _StringsLibrariesTabsEn { + _StringsLibrariesTabsEn._(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + String get recommended => 'Recommended'; + String get browse => 'Browse'; + String get collections => 'Collections'; + String get playlists => 'Playlists'; +} + +// Path: libraries.groupings +class _StringsLibrariesGroupingsEn { + _StringsLibrariesGroupingsEn._(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + String get all => 'All'; + String get movies => 'Movies'; + String get shows => 'TV Shows'; + String get seasons => 'Seasons'; + String get episodes => 'Episodes'; + String get folders => 'Folders'; +} + // Path: class _StringsDe implements Translations { /// You can call this constructor and build your own translation instance of this locale. @@ -841,6 +920,7 @@ class _StringsDe implements Translations { @override late final _StringsLicensesDe licenses = _StringsLicensesDe._(_root); @override late final _StringsNavigationDe navigation = _StringsNavigationDe._(_root); @override late final _StringsPlaylistsDe playlists = _StringsPlaylistsDe._(_root); + @override late final _StringsCollectionsDe collections = _StringsCollectionsDe._(_root); } // Path: app @@ -902,6 +982,9 @@ class _StringsCommonDe implements _StringsCommonEn { @override String get yes => 'Ja'; @override String get no => 'Nein'; @override String get server => 'Server'; + @override String get delete => 'Löschen'; + @override String get shuffle => 'Zufall'; + @override String get addTo => 'Hinzufügen zu...'; } // Path: screens @@ -1182,6 +1265,10 @@ class _StringsMessagesDe implements _StringsMessagesEn { @override String get noResultsFound => 'Keine Ergebnisse gefunden'; @override String sleepTimerSet({required Object label}) => 'Sleep-Timer gesetzt auf ${label}'; @override String failedToSwitchProfile({required Object displayName}) => 'Profilwechsel zu ${displayName} fehlgeschlagen'; + @override String get noItemsAvailable => 'Keine Elemente verfügbar'; + @override String get failedToCreatePlayQueue => 'Wiedergabewarteschlange konnte nicht erstellt werden'; + @override String get failedToCreatePlayQueueNoItems => 'Wiedergabewarteschlange konnte nicht erstellt werden – keine Elemente'; + @override String failedPlayback({required Object action, required Object error}) => 'Wiedergabe für ${action} fehlgeschlagen: ${error}'; } // Path: profile @@ -1314,6 +1401,15 @@ class _StringsLibrariesDe implements _StringsLibrariesEn { @override String get showLibrary => 'Mediathek anzeigen'; @override String get hideLibrary => 'Mediathek ausblenden'; @override String get libraryOptions => 'Mediatheksoptionen'; + @override String get content => 'Bibliotheksinhalt'; + @override String get selectLibrary => 'Bibliothek auswählen'; + @override String filtersWithCount({required Object count}) => 'Filter (${count})'; + @override String get noRecommendations => 'Keine Empfehlungen verfügbar'; + @override String get noCollections => 'Keine Sammlungen in dieser Mediathek'; + @override String get noFoldersFound => 'Keine Ordner gefunden'; + @override String get folders => 'Ordner'; + @override late final _StringsLibrariesTabsDe tabs = _StringsLibrariesTabsDe._(_root); + @override late final _StringsLibrariesGroupingsDe groupings = _StringsLibrariesGroupingsDe._(_root); } // Path: about @@ -1448,6 +1544,68 @@ class _StringsPlaylistsDe implements _StringsPlaylistsEn { @override String get errorAdding => 'Konnte nicht zur Wiedergabeliste hinzugefügt werden'; @override String get errorReordering => 'Element der Wiedergabeliste konnte nicht neu geordnet werden'; @override String get errorRemoving => 'Konnte nicht aus der Wiedergabeliste entfernt werden'; + @override String get playlist => 'Wiedergabeliste'; +} + +// Path: collections +class _StringsCollectionsDe implements _StringsCollectionsEn { + _StringsCollectionsDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get title => 'Sammlungen'; + @override String get collection => 'Sammlung'; + @override String get empty => 'Sammlung ist leer'; + @override String get noItems => 'Keine Elemente in dieser Sammlung'; + @override String get unknownLibrarySection => 'Löschen nicht möglich: Unbekannte Bibliothekssektion'; + @override String get deleteCollection => 'Sammlung löschen'; + @override String deleteConfirm({required Object title}) => 'Sind Sie sicher, dass Sie "${title}" löschen möchten? Dies kann nicht rückgängig gemacht werden.'; + @override String get deleted => 'Sammlung gelöscht'; + @override String get deleteFailed => 'Sammlung konnte nicht gelöscht werden'; + @override String deleteFailedWithError({required Object error}) => 'Sammlung konnte nicht gelöscht werden: ${error}'; + @override String failedToLoadItems({required Object error}) => 'Sammlungselemente konnten nicht geladen werden: ${error}'; + @override String get addTo => 'Zur Sammlung hinzufügen'; + @override String get selectCollection => 'Sammlung auswählen'; + @override String get createNewCollection => 'Neue Sammlung erstellen'; + @override String get collectionName => 'Sammlungsname'; + @override String get enterCollectionName => 'Sammlungsnamen eingeben'; + @override String get addedToCollection => 'Zur Sammlung hinzugefügt'; + @override String get errorAddingToCollection => 'Fehler beim Hinzufügen zur Sammlung'; + @override String get created => 'Sammlung erstellt'; + @override String get removeFromCollection => 'Aus Sammlung entfernen'; + @override String removeFromCollectionConfirm({required Object title}) => '"${title}" aus dieser Sammlung entfernen?'; + @override String get removedFromCollection => 'Aus Sammlung entfernt'; + @override String get removeFromCollectionFailed => 'Entfernen aus Sammlung fehlgeschlagen'; + @override String removeFromCollectionError({required Object error}) => 'Fehler beim Entfernen aus der Sammlung: ${error}'; +} + +// Path: libraries.tabs +class _StringsLibrariesTabsDe implements _StringsLibrariesTabsEn { + _StringsLibrariesTabsDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get recommended => 'Empfohlen'; + @override String get browse => 'Durchsuchen'; + @override String get collections => 'Sammlungen'; + @override String get playlists => 'Wiedergabelisten'; +} + +// Path: libraries.groupings +class _StringsLibrariesGroupingsDe implements _StringsLibrariesGroupingsEn { + _StringsLibrariesGroupingsDe._(this._root); + + @override final _StringsDe _root; // ignore: unused_field + + // Translations + @override String get all => 'Alle'; + @override String get movies => 'Filme'; + @override String get shows => 'Serien'; + @override String get seasons => 'Staffeln'; + @override String get episodes => 'Episoden'; + @override String get folders => 'Ordner'; } // Path: @@ -1502,6 +1660,7 @@ class _StringsIt implements Translations { @override late final _StringsLicensesIt licenses = _StringsLicensesIt._(_root); @override late final _StringsNavigationIt navigation = _StringsNavigationIt._(_root); @override late final _StringsPlaylistsIt playlists = _StringsPlaylistsIt._(_root); + @override late final _StringsCollectionsIt collections = _StringsCollectionsIt._(_root); } // Path: app @@ -1563,6 +1722,9 @@ class _StringsCommonIt implements _StringsCommonEn { @override String get yes => 'Sì'; @override String get no => 'No'; @override String get server => 'Server'; + @override String get delete => 'Elimina'; + @override String get shuffle => 'Casuale'; + @override String get addTo => 'Aggiungi a...'; } // Path: screens @@ -1843,6 +2005,10 @@ class _StringsMessagesIt implements _StringsMessagesEn { @override String get noResultsFound => 'Nessun risultato'; @override String sleepTimerSet({required Object label}) => 'Imposta timer spegnimento per ${label}'; @override String failedToSwitchProfile({required Object displayName}) => 'Impossibile passare a ${displayName}'; + @override String get noItemsAvailable => 'Nessun elemento disponibile'; + @override String get failedToCreatePlayQueue => 'Impossibile creare la coda di riproduzione'; + @override String get failedToCreatePlayQueueNoItems => 'Impossibile creare la coda di riproduzione - nessun elemento'; + @override String failedPlayback({required Object action, required Object error}) => 'Impossibile ${action}: ${error}'; } // Path: profile @@ -1975,6 +2141,15 @@ class _StringsLibrariesIt implements _StringsLibrariesEn { @override String get showLibrary => 'Mostra libreria'; @override String get hideLibrary => 'Nascondi libreria'; @override String get libraryOptions => 'Opzioni libreria'; + @override String get content => 'contenuto della libreria'; + @override String get selectLibrary => 'Seleziona libreria'; + @override String filtersWithCount({required Object count}) => 'Filtri (${count})'; + @override String get noRecommendations => 'Nessun consiglio disponibile'; + @override String get noCollections => 'Nessuna raccolta in questa libreria'; + @override String get noFoldersFound => 'Nessuna cartella trovata'; + @override String get folders => 'cartelle'; + @override late final _StringsLibrariesTabsIt tabs = _StringsLibrariesTabsIt._(_root); + @override late final _StringsLibrariesGroupingsIt groupings = _StringsLibrariesGroupingsIt._(_root); } // Path: about @@ -2109,6 +2284,68 @@ class _StringsPlaylistsIt implements _StringsPlaylistsEn { @override String get errorAdding => 'Errore durante l\'aggiunta alla playlist'; @override String get errorReordering => 'Errore durante il riordino dell\'elemento della playlist'; @override String get errorRemoving => 'Errore durante la rimozione dalla playlist'; + @override String get playlist => 'Playlist'; +} + +// Path: collections +class _StringsCollectionsIt implements _StringsCollectionsEn { + _StringsCollectionsIt._(this._root); + + @override final _StringsIt _root; // ignore: unused_field + + // Translations + @override String get title => 'Raccolte'; + @override String get collection => 'Raccolta'; + @override String get empty => 'La raccolta è vuota'; + @override String get noItems => 'Nessun elemento in questa raccolta'; + @override String get unknownLibrarySection => 'Impossibile eliminare: sezione libreria sconosciuta'; + @override String get deleteCollection => 'Elimina raccolta'; + @override String deleteConfirm({required Object title}) => 'Sei sicuro di voler eliminare "${title}"? Questa azione non può essere annullata.'; + @override String get deleted => 'Raccolta eliminata'; + @override String get deleteFailed => 'Impossibile eliminare la raccolta'; + @override String deleteFailedWithError({required Object error}) => 'Impossibile eliminare la raccolta: ${error}'; + @override String failedToLoadItems({required Object error}) => 'Impossibile caricare gli elementi della raccolta: ${error}'; + @override String get addTo => 'Aggiungi alla raccolta'; + @override String get selectCollection => 'Seleziona raccolta'; + @override String get createNewCollection => 'Crea nuova raccolta'; + @override String get collectionName => 'Nome raccolta'; + @override String get enterCollectionName => 'Inserisci nome raccolta'; + @override String get addedToCollection => 'Aggiunto alla raccolta'; + @override String get errorAddingToCollection => 'Errore nell\'aggiunta alla raccolta'; + @override String get created => 'Raccolta creata'; + @override String get removeFromCollection => 'Rimuovi dalla raccolta'; + @override String removeFromCollectionConfirm({required Object title}) => 'Rimuovere "${title}" da questa raccolta?'; + @override String get removedFromCollection => 'Rimosso dalla raccolta'; + @override String get removeFromCollectionFailed => 'Impossibile rimuovere dalla raccolta'; + @override String removeFromCollectionError({required Object error}) => 'Errore durante la rimozione dalla raccolta: ${error}'; +} + +// Path: libraries.tabs +class _StringsLibrariesTabsIt implements _StringsLibrariesTabsEn { + _StringsLibrariesTabsIt._(this._root); + + @override final _StringsIt _root; // ignore: unused_field + + // Translations + @override String get recommended => 'Consigliati'; + @override String get browse => 'Esplora'; + @override String get collections => 'Raccolte'; + @override String get playlists => 'Playlist'; +} + +// Path: libraries.groupings +class _StringsLibrariesGroupingsIt implements _StringsLibrariesGroupingsEn { + _StringsLibrariesGroupingsIt._(this._root); + + @override final _StringsIt _root; // ignore: unused_field + + // Translations + @override String get all => 'Tutti'; + @override String get movies => 'Film'; + @override String get shows => 'Serie TV'; + @override String get seasons => 'Stagioni'; + @override String get episodes => 'Episodi'; + @override String get folders => 'Cartelle'; } // Path: @@ -2163,6 +2400,7 @@ class _StringsNl implements Translations { @override late final _StringsLicensesNl licenses = _StringsLicensesNl._(_root); @override late final _StringsNavigationNl navigation = _StringsNavigationNl._(_root); @override late final _StringsPlaylistsNl playlists = _StringsPlaylistsNl._(_root); + @override late final _StringsCollectionsNl collections = _StringsCollectionsNl._(_root); } // Path: app @@ -2224,6 +2462,9 @@ class _StringsCommonNl implements _StringsCommonEn { @override String get yes => 'Ja'; @override String get no => 'Nee'; @override String get server => 'Server'; + @override String get delete => 'Verwijderen'; + @override String get shuffle => 'Shuffle'; + @override String get addTo => 'Toevoegen aan...'; } // Path: screens @@ -2504,6 +2745,10 @@ class _StringsMessagesNl implements _StringsMessagesEn { @override String get noResultsFound => 'Geen resultaten gevonden'; @override String sleepTimerSet({required Object label}) => 'Slaap timer ingesteld voor ${label}'; @override String failedToSwitchProfile({required Object displayName}) => 'Kon niet wisselen naar ${displayName}'; + @override String get noItemsAvailable => 'Geen items beschikbaar'; + @override String get failedToCreatePlayQueue => 'Kan afspeelwachtrij niet maken'; + @override String get failedToCreatePlayQueueNoItems => 'Kan afspeelwachtrij niet maken - geen items'; + @override String failedPlayback({required Object action, required Object error}) => 'Afspelen van ${action} mislukt: ${error}'; } // Path: profile @@ -2636,6 +2881,15 @@ class _StringsLibrariesNl implements _StringsLibrariesEn { @override String get showLibrary => 'Toon bibliotheek'; @override String get hideLibrary => 'Verberg bibliotheek'; @override String get libraryOptions => 'Bibliotheek opties'; + @override String get content => 'bibliotheekinhoud'; + @override String get selectLibrary => 'Bibliotheek kiezen'; + @override String filtersWithCount({required Object count}) => 'Filters (${count})'; + @override String get noRecommendations => 'Geen aanbevelingen beschikbaar'; + @override String get noCollections => 'Geen collecties in deze bibliotheek'; + @override String get noFoldersFound => 'Geen mappen gevonden'; + @override String get folders => 'mappen'; + @override late final _StringsLibrariesTabsNl tabs = _StringsLibrariesTabsNl._(_root); + @override late final _StringsLibrariesGroupingsNl groupings = _StringsLibrariesGroupingsNl._(_root); } // Path: about @@ -2770,6 +3024,68 @@ class _StringsPlaylistsNl implements _StringsPlaylistsEn { @override String get errorAdding => 'Fout bij toevoegen aan afspeellijst'; @override String get errorReordering => 'Fout bij herschikken van afspeellijstitem'; @override String get errorRemoving => 'Fout bij verwijderen uit afspeellijst'; + @override String get playlist => 'Afspeellijst'; +} + +// Path: collections +class _StringsCollectionsNl implements _StringsCollectionsEn { + _StringsCollectionsNl._(this._root); + + @override final _StringsNl _root; // ignore: unused_field + + // Translations + @override String get title => 'Collecties'; + @override String get collection => 'Collectie'; + @override String get empty => 'Collectie is leeg'; + @override String get noItems => 'Geen items in deze collectie'; + @override String get unknownLibrarySection => 'Kan niet verwijderen: onbekende bibliotheeksectie'; + @override String get deleteCollection => 'Collectie verwijderen'; + @override String deleteConfirm({required Object title}) => 'Weet je zeker dat je "${title}" wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.'; + @override String get deleted => 'Collectie verwijderd'; + @override String get deleteFailed => 'Collectie verwijderen mislukt'; + @override String deleteFailedWithError({required Object error}) => 'Collectie verwijderen mislukt: ${error}'; + @override String failedToLoadItems({required Object error}) => 'Collectie-items laden mislukt: ${error}'; + @override String get addTo => 'Toevoegen aan collectie'; + @override String get selectCollection => 'Selecteer collectie'; + @override String get createNewCollection => 'Nieuwe collectie maken'; + @override String get collectionName => 'Collectienaam'; + @override String get enterCollectionName => 'Voer collectienaam in'; + @override String get addedToCollection => 'Toegevoegd aan collectie'; + @override String get errorAddingToCollection => 'Fout bij toevoegen aan collectie'; + @override String get created => 'Collectie gemaakt'; + @override String get removeFromCollection => 'Verwijderen uit collectie'; + @override String removeFromCollectionConfirm({required Object title}) => '"${title}" uit deze collectie verwijderen?'; + @override String get removedFromCollection => 'Uit collectie verwijderd'; + @override String get removeFromCollectionFailed => 'Verwijderen uit collectie mislukt'; + @override String removeFromCollectionError({required Object error}) => 'Fout bij verwijderen uit collectie: ${error}'; +} + +// Path: libraries.tabs +class _StringsLibrariesTabsNl implements _StringsLibrariesTabsEn { + _StringsLibrariesTabsNl._(this._root); + + @override final _StringsNl _root; // ignore: unused_field + + // Translations + @override String get recommended => 'Aanbevolen'; + @override String get browse => 'Bladeren'; + @override String get collections => 'Collecties'; + @override String get playlists => 'Afspeellijsten'; +} + +// Path: libraries.groupings +class _StringsLibrariesGroupingsNl implements _StringsLibrariesGroupingsEn { + _StringsLibrariesGroupingsNl._(this._root); + + @override final _StringsNl _root; // ignore: unused_field + + // Translations + @override String get all => 'Alles'; + @override String get movies => 'Films'; + @override String get shows => 'Series'; + @override String get seasons => 'Seizoenen'; + @override String get episodes => 'Afleveringen'; + @override String get folders => 'Mappen'; } // Path: @@ -2824,6 +3140,7 @@ class _StringsSv implements Translations { @override late final _StringsLicensesSv licenses = _StringsLicensesSv._(_root); @override late final _StringsNavigationSv navigation = _StringsNavigationSv._(_root); @override late final _StringsPlaylistsSv playlists = _StringsPlaylistsSv._(_root); + @override late final _StringsCollectionsSv collections = _StringsCollectionsSv._(_root); } // Path: app @@ -2885,6 +3202,9 @@ class _StringsCommonSv implements _StringsCommonEn { @override String get yes => 'Ja'; @override String get no => 'Nej'; @override String get server => 'Server'; + @override String get delete => 'Ta bort'; + @override String get shuffle => 'Blanda'; + @override String get addTo => 'Lägg till i...'; } // Path: screens @@ -3165,6 +3485,10 @@ class _StringsMessagesSv implements _StringsMessagesEn { @override String get noResultsFound => 'Inga resultat hittades'; @override String sleepTimerSet({required Object label}) => 'Sovtimer inställd för ${label}'; @override String failedToSwitchProfile({required Object displayName}) => 'Misslyckades att byta till ${displayName}'; + @override String get noItemsAvailable => 'Inga objekt tillgängliga'; + @override String get failedToCreatePlayQueue => 'Det gick inte att skapa uppspelningskö'; + @override String get failedToCreatePlayQueueNoItems => 'Det gick inte att skapa uppspelningskö – inga objekt'; + @override String failedPlayback({required Object action, required Object error}) => 'Kunde inte ${action}: ${error}'; } // Path: profile @@ -3297,6 +3621,15 @@ class _StringsLibrariesSv implements _StringsLibrariesEn { @override String get showLibrary => 'Visa bibliotek'; @override String get hideLibrary => 'Dölj bibliotek'; @override String get libraryOptions => 'Biblioteksalternativ'; + @override String get content => 'bibliotekets innehåll'; + @override String get selectLibrary => 'Välj bibliotek'; + @override String filtersWithCount({required Object count}) => 'Filter (${count})'; + @override String get noRecommendations => 'Inga rekommendationer tillgängliga'; + @override String get noCollections => 'Inga samlingar i det här biblioteket'; + @override String get noFoldersFound => 'Inga mappar hittades'; + @override String get folders => 'mappar'; + @override late final _StringsLibrariesTabsSv tabs = _StringsLibrariesTabsSv._(_root); + @override late final _StringsLibrariesGroupingsSv groupings = _StringsLibrariesGroupingsSv._(_root); } // Path: about @@ -3431,6 +3764,68 @@ class _StringsPlaylistsSv implements _StringsPlaylistsEn { @override String get errorAdding => 'Det gick inte att lägga till i spellista'; @override String get errorReordering => 'Det gick inte att omordna spellisteobjekt'; @override String get errorRemoving => 'Det gick inte att ta bort från spellista'; + @override String get playlist => 'Spellista'; +} + +// Path: collections +class _StringsCollectionsSv implements _StringsCollectionsEn { + _StringsCollectionsSv._(this._root); + + @override final _StringsSv _root; // ignore: unused_field + + // Translations + @override String get title => 'Samlingar'; + @override String get collection => 'Samling'; + @override String get empty => 'Samlingen är tom'; + @override String get noItems => 'Inga objekt i den här samlingen'; + @override String get unknownLibrarySection => 'Kan inte ta bort: okänd bibliotekssektion'; + @override String get deleteCollection => 'Ta bort samling'; + @override String deleteConfirm({required Object title}) => 'Är du säker på att du vill ta bort "${title}"? Detta går inte att ångra.'; + @override String get deleted => 'Samling borttagen'; + @override String get deleteFailed => 'Det gick inte att ta bort samlingen'; + @override String deleteFailedWithError({required Object error}) => 'Det gick inte att ta bort samlingen: ${error}'; + @override String failedToLoadItems({required Object error}) => 'Det gick inte att läsa in samlingsobjekt: ${error}'; + @override String get addTo => 'Lägg till i samling'; + @override String get selectCollection => 'Välj samling'; + @override String get createNewCollection => 'Skapa ny samling'; + @override String get collectionName => 'Samlingsnamn'; + @override String get enterCollectionName => 'Ange samlingsnamn'; + @override String get addedToCollection => 'Tillagd i samling'; + @override String get errorAddingToCollection => 'Fel vid tillägg i samling'; + @override String get created => 'Samling skapad'; + @override String get removeFromCollection => 'Ta bort från samling'; + @override String removeFromCollectionConfirm({required Object title}) => 'Ta bort "${title}" från denna samling?'; + @override String get removedFromCollection => 'Borttagen från samling'; + @override String get removeFromCollectionFailed => 'Misslyckades med att ta bort från samling'; + @override String removeFromCollectionError({required Object error}) => 'Fel vid borttagning från samling: ${error}'; +} + +// Path: libraries.tabs +class _StringsLibrariesTabsSv implements _StringsLibrariesTabsEn { + _StringsLibrariesTabsSv._(this._root); + + @override final _StringsSv _root; // ignore: unused_field + + // Translations + @override String get recommended => 'Rekommenderat'; + @override String get browse => 'Bläddra'; + @override String get collections => 'Samlingar'; + @override String get playlists => 'Spellistor'; +} + +// Path: libraries.groupings +class _StringsLibrariesGroupingsSv implements _StringsLibrariesGroupingsEn { + _StringsLibrariesGroupingsSv._(this._root); + + @override final _StringsSv _root; // ignore: unused_field + + // Translations + @override String get all => 'Alla'; + @override String get movies => 'Filmer'; + @override String get shows => 'Serier'; + @override String get seasons => 'Säsonger'; + @override String get episodes => 'Avsnitt'; + @override String get folders => 'Mappar'; } // Path: @@ -3485,6 +3880,7 @@ class _StringsZh implements Translations { @override late final _StringsLicensesZh licenses = _StringsLicensesZh._(_root); @override late final _StringsNavigationZh navigation = _StringsNavigationZh._(_root); @override late final _StringsPlaylistsZh playlists = _StringsPlaylistsZh._(_root); + @override late final _StringsCollectionsZh collections = _StringsCollectionsZh._(_root); } // Path: app @@ -3546,6 +3942,9 @@ class _StringsCommonZh implements _StringsCommonEn { @override String get yes => '是'; @override String get no => '否'; @override String get server => '服务器'; + @override String get delete => '删除'; + @override String get shuffle => '随机播放'; + @override String get addTo => '添加到...'; } // Path: screens @@ -3826,6 +4225,10 @@ class _StringsMessagesZh implements _StringsMessagesEn { @override String get noResultsFound => '未找到结果'; @override String sleepTimerSet({required Object label}) => '睡眠定时器已设置为 ${label}'; @override String failedToSwitchProfile({required Object displayName}) => '无法切换到 ${displayName}'; + @override String get noItemsAvailable => '没有可用的项目'; + @override String get failedToCreatePlayQueue => '创建播放队列失败'; + @override String get failedToCreatePlayQueueNoItems => '创建播放队列失败 - 没有项目'; + @override String failedPlayback({required Object action, required Object error}) => '无法${action}: ${error}'; } // Path: profile @@ -3958,6 +4361,15 @@ class _StringsLibrariesZh implements _StringsLibrariesEn { @override String get showLibrary => '显示媒体库'; @override String get hideLibrary => '隐藏媒体库'; @override String get libraryOptions => '媒体库选项'; + @override String get content => '媒体库内容'; + @override String get selectLibrary => '选择媒体库'; + @override String filtersWithCount({required Object count}) => '筛选器(${count})'; + @override String get noRecommendations => '暂无推荐'; + @override String get noCollections => '此媒体库中没有合集'; + @override String get noFoldersFound => '未找到文件夹'; + @override String get folders => '文件夹'; + @override late final _StringsLibrariesTabsZh tabs = _StringsLibrariesTabsZh._(_root); + @override late final _StringsLibrariesGroupingsZh groupings = _StringsLibrariesGroupingsZh._(_root); } // Path: about @@ -4092,6 +4504,68 @@ class _StringsPlaylistsZh implements _StringsPlaylistsEn { @override String get errorAdding => '添加到播放列表失败'; @override String get errorReordering => '重新排序播放列表项目失败'; @override String get errorRemoving => '从播放列表中移除失败'; + @override String get playlist => '播放列表'; +} + +// Path: collections +class _StringsCollectionsZh implements _StringsCollectionsEn { + _StringsCollectionsZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get title => '合集'; + @override String get collection => '合集'; + @override String get empty => '合集为空'; + @override String get noItems => '此合集没有项目'; + @override String get unknownLibrarySection => '无法删除:未知的媒体库分区'; + @override String get deleteCollection => '删除合集'; + @override String deleteConfirm({required Object title}) => '确定要删除"${title}"吗?此操作无法撤销。'; + @override String get deleted => '已删除合集'; + @override String get deleteFailed => '删除合集失败'; + @override String deleteFailedWithError({required Object error}) => '删除合集失败:${error}'; + @override String failedToLoadItems({required Object error}) => '加载合集项目失败:${error}'; + @override String get addTo => '添加到合集'; + @override String get selectCollection => '选择合集'; + @override String get createNewCollection => '创建新合集'; + @override String get collectionName => '合集名称'; + @override String get enterCollectionName => '输入合集名称'; + @override String get addedToCollection => '已添加到合集'; + @override String get errorAddingToCollection => '添加到合集失败'; + @override String get created => '已创建合集'; + @override String get removeFromCollection => '从合集移除'; + @override String removeFromCollectionConfirm({required Object title}) => '将“${title}”从此合集移除?'; + @override String get removedFromCollection => '已从合集移除'; + @override String get removeFromCollectionFailed => '从合集移除失败'; + @override String removeFromCollectionError({required Object error}) => '从合集移除时出错:${error}'; +} + +// Path: libraries.tabs +class _StringsLibrariesTabsZh implements _StringsLibrariesTabsEn { + _StringsLibrariesTabsZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get recommended => '推荐'; + @override String get browse => '浏览'; + @override String get collections => '合集'; + @override String get playlists => '播放列表'; +} + +// Path: libraries.groupings +class _StringsLibrariesGroupingsZh implements _StringsLibrariesGroupingsEn { + _StringsLibrariesGroupingsZh._(this._root); + + @override final _StringsZh _root; // ignore: unused_field + + // Translations + @override String get all => '全部'; + @override String get movies => '电影'; + @override String get shows => '剧集'; + @override String get seasons => '季'; + @override String get episodes => '集'; + @override String get folders => '文件夹'; } /// Flat map(s) containing all translations. @@ -4134,6 +4608,9 @@ extension on Translations { case 'common.yes': return 'Yes'; case 'common.no': return 'No'; case 'common.server': return 'Server'; + case 'common.delete': return 'Delete'; + case 'common.shuffle': return 'Shuffle'; + case 'common.addTo': return 'Add to...'; case 'screens.licenses': return 'Licenses'; case 'screens.selectServer': return 'Select Server'; case 'screens.switchProfile': return 'Switch Profile'; @@ -4306,6 +4783,10 @@ extension on Translations { case 'messages.noResultsFound': return 'No results found'; case 'messages.sleepTimerSet': return ({required Object label}) => 'Sleep timer set for ${label}'; case 'messages.failedToSwitchProfile': return ({required Object displayName}) => 'Failed to switch to ${displayName}'; + case 'messages.noItemsAvailable': return 'No items available'; + case 'messages.failedToCreatePlayQueue': return 'Failed to create play queue'; + case 'messages.failedToCreatePlayQueueNoItems': return 'Failed to create play queue - no items'; + case 'messages.failedPlayback': return ({required Object action, required Object error}) => 'Failed to ${action}: ${error}'; case 'profile.noUsersAvailable': return 'No users available'; case 'subtitlingStyling.stylingOptions': return 'Styling Options'; case 'subtitlingStyling.fontSize': return 'Font Size'; @@ -4384,6 +4865,23 @@ extension on Translations { case 'libraries.showLibrary': return 'Show library'; case 'libraries.hideLibrary': return 'Hide library'; case 'libraries.libraryOptions': return 'Library options'; + case 'libraries.content': return 'library content'; + case 'libraries.selectLibrary': return 'Select library'; + case 'libraries.filtersWithCount': return ({required Object count}) => 'Filters (${count})'; + case 'libraries.noRecommendations': return 'No recommendations available'; + case 'libraries.noCollections': return 'No collections in this library'; + case 'libraries.noFoldersFound': return 'No folders found'; + case 'libraries.folders': return 'folders'; + case 'libraries.tabs.recommended': return 'Recommended'; + case 'libraries.tabs.browse': return 'Browse'; + case 'libraries.tabs.collections': return 'Collections'; + case 'libraries.tabs.playlists': return 'Playlists'; + case 'libraries.groupings.all': return 'All'; + case 'libraries.groupings.movies': return 'Movies'; + case 'libraries.groupings.shows': return 'TV Shows'; + case 'libraries.groupings.seasons': return 'Seasons'; + case 'libraries.groupings.episodes': return 'Episodes'; + case 'libraries.groupings.folders': return 'Folders'; case 'about.title': return 'About'; case 'about.openSourceLicenses': return 'Open Source Licenses'; case 'about.versionLabel': return ({required Object version}) => 'Version ${version}'; @@ -4421,7 +4919,32 @@ extension on Translations { case 'navigation.search': return 'Search'; case 'navigation.libraries': return 'Libraries'; case 'navigation.settings': return 'Settings'; + case 'collections.title': return 'Collections'; + case 'collections.collection': return 'Collection'; + case 'collections.empty': return 'Collection is empty'; + case 'collections.noItems': return 'No items in this collection'; + case 'collections.unknownLibrarySection': return 'Cannot delete: Unknown library section'; + case 'collections.deleteCollection': return 'Delete Collection'; + case 'collections.deleteConfirm': return ({required Object title}) => 'Are you sure you want to delete "${title}"? This action cannot be undone.'; + case 'collections.deleted': return 'Collection deleted'; + case 'collections.deleteFailed': return 'Failed to delete collection'; + case 'collections.deleteFailedWithError': return ({required Object error}) => 'Failed to delete collection: ${error}'; + case 'collections.failedToLoadItems': return ({required Object error}) => 'Failed to load collection items: ${error}'; + case 'collections.addTo': return 'Add to collection'; + case 'collections.selectCollection': return 'Select Collection'; + case 'collections.createNewCollection': return 'Create New Collection'; + case 'collections.collectionName': return 'Collection Name'; + case 'collections.enterCollectionName': return 'Enter collection name'; + case 'collections.addedToCollection': return 'Added to collection'; + case 'collections.errorAddingToCollection': return 'Failed to add to collection'; + case 'collections.created': return 'Collection created'; + case 'collections.removeFromCollection': return 'Remove from collection'; + case 'collections.removeFromCollectionConfirm': return ({required Object title}) => 'Remove "${title}" from this collection?'; + case 'collections.removedFromCollection': return 'Removed from collection'; + case 'collections.removeFromCollectionFailed': return 'Failed to remove from collection'; + case 'collections.removeFromCollectionError': return ({required Object error}) => 'Error removing from collection: ${error}'; case 'playlists.title': return 'Playlists'; + case 'playlists.playlist': return 'Playlist'; case 'playlists.noPlaylists': return 'No playlists found'; case 'playlists.create': return 'Create Playlist'; case 'playlists.newPlaylist': return 'New Playlist'; @@ -4497,6 +5020,9 @@ extension on _StringsDe { case 'common.yes': return 'Ja'; case 'common.no': return 'Nein'; case 'common.server': return 'Server'; + case 'common.delete': return 'Löschen'; + case 'common.shuffle': return 'Zufall'; + case 'common.addTo': return 'Hinzufügen zu...'; case 'screens.licenses': return 'Lizenzen'; case 'screens.selectServer': return 'Server auswählen'; case 'screens.switchProfile': return 'Profil wechseln'; @@ -4669,6 +5195,10 @@ extension on _StringsDe { case 'messages.noResultsFound': return 'Keine Ergebnisse gefunden'; case 'messages.sleepTimerSet': return ({required Object label}) => 'Sleep-Timer gesetzt auf ${label}'; case 'messages.failedToSwitchProfile': return ({required Object displayName}) => 'Profilwechsel zu ${displayName} fehlgeschlagen'; + case 'messages.noItemsAvailable': return 'Keine Elemente verfügbar'; + case 'messages.failedToCreatePlayQueue': return 'Wiedergabewarteschlange konnte nicht erstellt werden'; + case 'messages.failedToCreatePlayQueueNoItems': return 'Wiedergabewarteschlange konnte nicht erstellt werden – keine Elemente'; + case 'messages.failedPlayback': return ({required Object action, required Object error}) => 'Wiedergabe für ${action} fehlgeschlagen: ${error}'; case 'profile.noUsersAvailable': return 'Keine Benutzer verfügbar'; case 'subtitlingStyling.stylingOptions': return 'Stiloptionen'; case 'subtitlingStyling.fontSize': return 'Schriftgröße'; @@ -4747,6 +5277,23 @@ extension on _StringsDe { case 'libraries.showLibrary': return 'Mediathek anzeigen'; case 'libraries.hideLibrary': return 'Mediathek ausblenden'; case 'libraries.libraryOptions': return 'Mediatheksoptionen'; + case 'libraries.content': return 'Bibliotheksinhalt'; + case 'libraries.selectLibrary': return 'Bibliothek auswählen'; + case 'libraries.filtersWithCount': return ({required Object count}) => 'Filter (${count})'; + case 'libraries.noRecommendations': return 'Keine Empfehlungen verfügbar'; + case 'libraries.noCollections': return 'Keine Sammlungen in dieser Mediathek'; + case 'libraries.noFoldersFound': return 'Keine Ordner gefunden'; + case 'libraries.folders': return 'Ordner'; + case 'libraries.tabs.recommended': return 'Empfohlen'; + case 'libraries.tabs.browse': return 'Durchsuchen'; + case 'libraries.tabs.collections': return 'Sammlungen'; + case 'libraries.tabs.playlists': return 'Wiedergabelisten'; + case 'libraries.groupings.all': return 'Alle'; + case 'libraries.groupings.movies': return 'Filme'; + case 'libraries.groupings.shows': return 'Serien'; + case 'libraries.groupings.seasons': return 'Staffeln'; + case 'libraries.groupings.episodes': return 'Episoden'; + case 'libraries.groupings.folders': return 'Ordner'; case 'about.title': return 'Über'; case 'about.openSourceLicenses': return 'Open-Source-Lizenzen'; case 'about.versionLabel': return ({required Object version}) => 'Version ${version}'; @@ -4818,6 +5365,31 @@ extension on _StringsDe { case 'playlists.errorAdding': return 'Konnte nicht zur Wiedergabeliste hinzugefügt werden'; case 'playlists.errorReordering': return 'Element der Wiedergabeliste konnte nicht neu geordnet werden'; case 'playlists.errorRemoving': return 'Konnte nicht aus der Wiedergabeliste entfernt werden'; + case 'playlists.playlist': return 'Wiedergabeliste'; + case 'collections.title': return 'Sammlungen'; + case 'collections.collection': return 'Sammlung'; + case 'collections.empty': return 'Sammlung ist leer'; + case 'collections.noItems': return 'Keine Elemente in dieser Sammlung'; + case 'collections.unknownLibrarySection': return 'Löschen nicht möglich: Unbekannte Bibliothekssektion'; + case 'collections.deleteCollection': return 'Sammlung löschen'; + case 'collections.deleteConfirm': return ({required Object title}) => 'Sind Sie sicher, dass Sie "${title}" löschen möchten? Dies kann nicht rückgängig gemacht werden.'; + case 'collections.deleted': return 'Sammlung gelöscht'; + case 'collections.deleteFailed': return 'Sammlung konnte nicht gelöscht werden'; + case 'collections.deleteFailedWithError': return ({required Object error}) => 'Sammlung konnte nicht gelöscht werden: ${error}'; + case 'collections.failedToLoadItems': return ({required Object error}) => 'Sammlungselemente konnten nicht geladen werden: ${error}'; + case 'collections.addTo': return 'Zur Sammlung hinzufügen'; + case 'collections.selectCollection': return 'Sammlung auswählen'; + case 'collections.createNewCollection': return 'Neue Sammlung erstellen'; + case 'collections.collectionName': return 'Sammlungsname'; + case 'collections.enterCollectionName': return 'Sammlungsnamen eingeben'; + case 'collections.addedToCollection': return 'Zur Sammlung hinzugefügt'; + case 'collections.errorAddingToCollection': return 'Fehler beim Hinzufügen zur Sammlung'; + case 'collections.created': return 'Sammlung erstellt'; + case 'collections.removeFromCollection': return 'Aus Sammlung entfernen'; + case 'collections.removeFromCollectionConfirm': return ({required Object title}) => '"${title}" aus dieser Sammlung entfernen?'; + case 'collections.removedFromCollection': return 'Aus Sammlung entfernt'; + case 'collections.removeFromCollectionFailed': return 'Entfernen aus Sammlung fehlgeschlagen'; + case 'collections.removeFromCollectionError': return ({required Object error}) => 'Fehler beim Entfernen aus der Sammlung: ${error}'; default: return null; } } @@ -4860,6 +5432,9 @@ extension on _StringsIt { case 'common.yes': return 'Sì'; case 'common.no': return 'No'; case 'common.server': return 'Server'; + case 'common.delete': return 'Elimina'; + case 'common.shuffle': return 'Casuale'; + case 'common.addTo': return 'Aggiungi a...'; case 'screens.licenses': return 'Licenze'; case 'screens.selectServer': return 'Seleziona server'; case 'screens.switchProfile': return 'Cambia profilo'; @@ -5032,6 +5607,10 @@ extension on _StringsIt { case 'messages.noResultsFound': return 'Nessun risultato'; case 'messages.sleepTimerSet': return ({required Object label}) => 'Imposta timer spegnimento per ${label}'; case 'messages.failedToSwitchProfile': return ({required Object displayName}) => 'Impossibile passare a ${displayName}'; + case 'messages.noItemsAvailable': return 'Nessun elemento disponibile'; + case 'messages.failedToCreatePlayQueue': return 'Impossibile creare la coda di riproduzione'; + case 'messages.failedToCreatePlayQueueNoItems': return 'Impossibile creare la coda di riproduzione - nessun elemento'; + case 'messages.failedPlayback': return ({required Object action, required Object error}) => 'Impossibile ${action}: ${error}'; case 'profile.noUsersAvailable': return 'Nessun utente disponibile'; case 'subtitlingStyling.stylingOptions': return 'Opzioni stile'; case 'subtitlingStyling.fontSize': return 'Dimensione'; @@ -5110,6 +5689,23 @@ extension on _StringsIt { case 'libraries.showLibrary': return 'Mostra libreria'; case 'libraries.hideLibrary': return 'Nascondi libreria'; case 'libraries.libraryOptions': return 'Opzioni libreria'; + case 'libraries.content': return 'contenuto della libreria'; + case 'libraries.selectLibrary': return 'Seleziona libreria'; + case 'libraries.filtersWithCount': return ({required Object count}) => 'Filtri (${count})'; + case 'libraries.noRecommendations': return 'Nessun consiglio disponibile'; + case 'libraries.noCollections': return 'Nessuna raccolta in questa libreria'; + case 'libraries.noFoldersFound': return 'Nessuna cartella trovata'; + case 'libraries.folders': return 'cartelle'; + case 'libraries.tabs.recommended': return 'Consigliati'; + case 'libraries.tabs.browse': return 'Esplora'; + case 'libraries.tabs.collections': return 'Raccolte'; + case 'libraries.tabs.playlists': return 'Playlist'; + case 'libraries.groupings.all': return 'Tutti'; + case 'libraries.groupings.movies': return 'Film'; + case 'libraries.groupings.shows': return 'Serie TV'; + case 'libraries.groupings.seasons': return 'Stagioni'; + case 'libraries.groupings.episodes': return 'Episodi'; + case 'libraries.groupings.folders': return 'Cartelle'; case 'about.title': return 'Informazioni'; case 'about.openSourceLicenses': return 'Licenze Open Source'; case 'about.versionLabel': return ({required Object version}) => 'Versione ${version}'; @@ -5181,6 +5777,31 @@ extension on _StringsIt { case 'playlists.errorAdding': return 'Errore durante l\'aggiunta alla playlist'; case 'playlists.errorReordering': return 'Errore durante il riordino dell\'elemento della playlist'; case 'playlists.errorRemoving': return 'Errore durante la rimozione dalla playlist'; + case 'playlists.playlist': return 'Playlist'; + case 'collections.title': return 'Raccolte'; + case 'collections.collection': return 'Raccolta'; + case 'collections.empty': return 'La raccolta è vuota'; + case 'collections.noItems': return 'Nessun elemento in questa raccolta'; + case 'collections.unknownLibrarySection': return 'Impossibile eliminare: sezione libreria sconosciuta'; + case 'collections.deleteCollection': return 'Elimina raccolta'; + case 'collections.deleteConfirm': return ({required Object title}) => 'Sei sicuro di voler eliminare "${title}"? Questa azione non può essere annullata.'; + case 'collections.deleted': return 'Raccolta eliminata'; + case 'collections.deleteFailed': return 'Impossibile eliminare la raccolta'; + case 'collections.deleteFailedWithError': return ({required Object error}) => 'Impossibile eliminare la raccolta: ${error}'; + case 'collections.failedToLoadItems': return ({required Object error}) => 'Impossibile caricare gli elementi della raccolta: ${error}'; + case 'collections.addTo': return 'Aggiungi alla raccolta'; + case 'collections.selectCollection': return 'Seleziona raccolta'; + case 'collections.createNewCollection': return 'Crea nuova raccolta'; + case 'collections.collectionName': return 'Nome raccolta'; + case 'collections.enterCollectionName': return 'Inserisci nome raccolta'; + case 'collections.addedToCollection': return 'Aggiunto alla raccolta'; + case 'collections.errorAddingToCollection': return 'Errore nell\'aggiunta alla raccolta'; + case 'collections.created': return 'Raccolta creata'; + case 'collections.removeFromCollection': return 'Rimuovi dalla raccolta'; + case 'collections.removeFromCollectionConfirm': return ({required Object title}) => 'Rimuovere "${title}" da questa raccolta?'; + case 'collections.removedFromCollection': return 'Rimosso dalla raccolta'; + case 'collections.removeFromCollectionFailed': return 'Impossibile rimuovere dalla raccolta'; + case 'collections.removeFromCollectionError': return ({required Object error}) => 'Errore durante la rimozione dalla raccolta: ${error}'; default: return null; } } @@ -5223,6 +5844,9 @@ extension on _StringsNl { case 'common.yes': return 'Ja'; case 'common.no': return 'Nee'; case 'common.server': return 'Server'; + case 'common.delete': return 'Verwijderen'; + case 'common.shuffle': return 'Shuffle'; + case 'common.addTo': return 'Toevoegen aan...'; case 'screens.licenses': return 'Licenties'; case 'screens.selectServer': return 'Selecteer server'; case 'screens.switchProfile': return 'Wissel van profiel'; @@ -5395,6 +6019,10 @@ extension on _StringsNl { case 'messages.noResultsFound': return 'Geen resultaten gevonden'; case 'messages.sleepTimerSet': return ({required Object label}) => 'Slaap timer ingesteld voor ${label}'; case 'messages.failedToSwitchProfile': return ({required Object displayName}) => 'Kon niet wisselen naar ${displayName}'; + case 'messages.noItemsAvailable': return 'Geen items beschikbaar'; + case 'messages.failedToCreatePlayQueue': return 'Kan afspeelwachtrij niet maken'; + case 'messages.failedToCreatePlayQueueNoItems': return 'Kan afspeelwachtrij niet maken - geen items'; + case 'messages.failedPlayback': return ({required Object action, required Object error}) => 'Afspelen van ${action} mislukt: ${error}'; case 'profile.noUsersAvailable': return 'Geen gebruikers beschikbaar'; case 'subtitlingStyling.stylingOptions': return 'Opmaak opties'; case 'subtitlingStyling.fontSize': return 'Lettergrootte'; @@ -5473,6 +6101,23 @@ extension on _StringsNl { case 'libraries.showLibrary': return 'Toon bibliotheek'; case 'libraries.hideLibrary': return 'Verberg bibliotheek'; case 'libraries.libraryOptions': return 'Bibliotheek opties'; + case 'libraries.content': return 'bibliotheekinhoud'; + case 'libraries.selectLibrary': return 'Bibliotheek kiezen'; + case 'libraries.filtersWithCount': return ({required Object count}) => 'Filters (${count})'; + case 'libraries.noRecommendations': return 'Geen aanbevelingen beschikbaar'; + case 'libraries.noCollections': return 'Geen collecties in deze bibliotheek'; + case 'libraries.noFoldersFound': return 'Geen mappen gevonden'; + case 'libraries.folders': return 'mappen'; + case 'libraries.tabs.recommended': return 'Aanbevolen'; + case 'libraries.tabs.browse': return 'Bladeren'; + case 'libraries.tabs.collections': return 'Collecties'; + case 'libraries.tabs.playlists': return 'Afspeellijsten'; + case 'libraries.groupings.all': return 'Alles'; + case 'libraries.groupings.movies': return 'Films'; + case 'libraries.groupings.shows': return 'Series'; + case 'libraries.groupings.seasons': return 'Seizoenen'; + case 'libraries.groupings.episodes': return 'Afleveringen'; + case 'libraries.groupings.folders': return 'Mappen'; case 'about.title': return 'Over'; case 'about.openSourceLicenses': return 'Open Source licenties'; case 'about.versionLabel': return ({required Object version}) => 'Versie ${version}'; @@ -5544,6 +6189,31 @@ extension on _StringsNl { case 'playlists.errorAdding': return 'Fout bij toevoegen aan afspeellijst'; case 'playlists.errorReordering': return 'Fout bij herschikken van afspeellijstitem'; case 'playlists.errorRemoving': return 'Fout bij verwijderen uit afspeellijst'; + case 'playlists.playlist': return 'Afspeellijst'; + case 'collections.title': return 'Collecties'; + case 'collections.collection': return 'Collectie'; + case 'collections.empty': return 'Collectie is leeg'; + case 'collections.noItems': return 'Geen items in deze collectie'; + case 'collections.unknownLibrarySection': return 'Kan niet verwijderen: onbekende bibliotheeksectie'; + case 'collections.deleteCollection': return 'Collectie verwijderen'; + case 'collections.deleteConfirm': return ({required Object title}) => 'Weet je zeker dat je "${title}" wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.'; + case 'collections.deleted': return 'Collectie verwijderd'; + case 'collections.deleteFailed': return 'Collectie verwijderen mislukt'; + case 'collections.deleteFailedWithError': return ({required Object error}) => 'Collectie verwijderen mislukt: ${error}'; + case 'collections.failedToLoadItems': return ({required Object error}) => 'Collectie-items laden mislukt: ${error}'; + case 'collections.addTo': return 'Toevoegen aan collectie'; + case 'collections.selectCollection': return 'Selecteer collectie'; + case 'collections.createNewCollection': return 'Nieuwe collectie maken'; + case 'collections.collectionName': return 'Collectienaam'; + case 'collections.enterCollectionName': return 'Voer collectienaam in'; + case 'collections.addedToCollection': return 'Toegevoegd aan collectie'; + case 'collections.errorAddingToCollection': return 'Fout bij toevoegen aan collectie'; + case 'collections.created': return 'Collectie gemaakt'; + case 'collections.removeFromCollection': return 'Verwijderen uit collectie'; + case 'collections.removeFromCollectionConfirm': return ({required Object title}) => '"${title}" uit deze collectie verwijderen?'; + case 'collections.removedFromCollection': return 'Uit collectie verwijderd'; + case 'collections.removeFromCollectionFailed': return 'Verwijderen uit collectie mislukt'; + case 'collections.removeFromCollectionError': return ({required Object error}) => 'Fout bij verwijderen uit collectie: ${error}'; default: return null; } } @@ -5586,6 +6256,9 @@ extension on _StringsSv { case 'common.yes': return 'Ja'; case 'common.no': return 'Nej'; case 'common.server': return 'Server'; + case 'common.delete': return 'Ta bort'; + case 'common.shuffle': return 'Blanda'; + case 'common.addTo': return 'Lägg till i...'; case 'screens.licenses': return 'Licenser'; case 'screens.selectServer': return 'Välj server'; case 'screens.switchProfile': return 'Byt profil'; @@ -5758,6 +6431,10 @@ extension on _StringsSv { case 'messages.noResultsFound': return 'Inga resultat hittades'; case 'messages.sleepTimerSet': return ({required Object label}) => 'Sovtimer inställd för ${label}'; case 'messages.failedToSwitchProfile': return ({required Object displayName}) => 'Misslyckades att byta till ${displayName}'; + case 'messages.noItemsAvailable': return 'Inga objekt tillgängliga'; + case 'messages.failedToCreatePlayQueue': return 'Det gick inte att skapa uppspelningskö'; + case 'messages.failedToCreatePlayQueueNoItems': return 'Det gick inte att skapa uppspelningskö – inga objekt'; + case 'messages.failedPlayback': return ({required Object action, required Object error}) => 'Kunde inte ${action}: ${error}'; case 'profile.noUsersAvailable': return 'Inga användare tillgängliga'; case 'subtitlingStyling.stylingOptions': return 'Stilalternativ'; case 'subtitlingStyling.fontSize': return 'Teckenstorlek'; @@ -5836,6 +6513,23 @@ extension on _StringsSv { case 'libraries.showLibrary': return 'Visa bibliotek'; case 'libraries.hideLibrary': return 'Dölj bibliotek'; case 'libraries.libraryOptions': return 'Biblioteksalternativ'; + case 'libraries.content': return 'bibliotekets innehåll'; + case 'libraries.selectLibrary': return 'Välj bibliotek'; + case 'libraries.filtersWithCount': return ({required Object count}) => 'Filter (${count})'; + case 'libraries.noRecommendations': return 'Inga rekommendationer tillgängliga'; + case 'libraries.noCollections': return 'Inga samlingar i det här biblioteket'; + case 'libraries.noFoldersFound': return 'Inga mappar hittades'; + case 'libraries.folders': return 'mappar'; + case 'libraries.tabs.recommended': return 'Rekommenderat'; + case 'libraries.tabs.browse': return 'Bläddra'; + case 'libraries.tabs.collections': return 'Samlingar'; + case 'libraries.tabs.playlists': return 'Spellistor'; + case 'libraries.groupings.all': return 'Alla'; + case 'libraries.groupings.movies': return 'Filmer'; + case 'libraries.groupings.shows': return 'Serier'; + case 'libraries.groupings.seasons': return 'Säsonger'; + case 'libraries.groupings.episodes': return 'Avsnitt'; + case 'libraries.groupings.folders': return 'Mappar'; case 'about.title': return 'Om'; case 'about.openSourceLicenses': return 'Öppen källkod-licenser'; case 'about.versionLabel': return ({required Object version}) => 'Version ${version}'; @@ -5907,6 +6601,31 @@ extension on _StringsSv { case 'playlists.errorAdding': return 'Det gick inte att lägga till i spellista'; case 'playlists.errorReordering': return 'Det gick inte att omordna spellisteobjekt'; case 'playlists.errorRemoving': return 'Det gick inte att ta bort från spellista'; + case 'playlists.playlist': return 'Spellista'; + case 'collections.title': return 'Samlingar'; + case 'collections.collection': return 'Samling'; + case 'collections.empty': return 'Samlingen är tom'; + case 'collections.noItems': return 'Inga objekt i den här samlingen'; + case 'collections.unknownLibrarySection': return 'Kan inte ta bort: okänd bibliotekssektion'; + case 'collections.deleteCollection': return 'Ta bort samling'; + case 'collections.deleteConfirm': return ({required Object title}) => 'Är du säker på att du vill ta bort "${title}"? Detta går inte att ångra.'; + case 'collections.deleted': return 'Samling borttagen'; + case 'collections.deleteFailed': return 'Det gick inte att ta bort samlingen'; + case 'collections.deleteFailedWithError': return ({required Object error}) => 'Det gick inte att ta bort samlingen: ${error}'; + case 'collections.failedToLoadItems': return ({required Object error}) => 'Det gick inte att läsa in samlingsobjekt: ${error}'; + case 'collections.addTo': return 'Lägg till i samling'; + case 'collections.selectCollection': return 'Välj samling'; + case 'collections.createNewCollection': return 'Skapa ny samling'; + case 'collections.collectionName': return 'Samlingsnamn'; + case 'collections.enterCollectionName': return 'Ange samlingsnamn'; + case 'collections.addedToCollection': return 'Tillagd i samling'; + case 'collections.errorAddingToCollection': return 'Fel vid tillägg i samling'; + case 'collections.created': return 'Samling skapad'; + case 'collections.removeFromCollection': return 'Ta bort från samling'; + case 'collections.removeFromCollectionConfirm': return ({required Object title}) => 'Ta bort "${title}" från denna samling?'; + case 'collections.removedFromCollection': return 'Borttagen från samling'; + case 'collections.removeFromCollectionFailed': return 'Misslyckades med att ta bort från samling'; + case 'collections.removeFromCollectionError': return ({required Object error}) => 'Fel vid borttagning från samling: ${error}'; default: return null; } } @@ -5949,6 +6668,9 @@ extension on _StringsZh { case 'common.yes': return '是'; case 'common.no': return '否'; case 'common.server': return '服务器'; + case 'common.delete': return '删除'; + case 'common.shuffle': return '随机播放'; + case 'common.addTo': return '添加到...'; case 'screens.licenses': return '许可证'; case 'screens.selectServer': return '选择服务器'; case 'screens.switchProfile': return '切换配置文件'; @@ -6121,6 +6843,10 @@ extension on _StringsZh { case 'messages.noResultsFound': return '未找到结果'; case 'messages.sleepTimerSet': return ({required Object label}) => '睡眠定时器已设置为 ${label}'; case 'messages.failedToSwitchProfile': return ({required Object displayName}) => '无法切换到 ${displayName}'; + case 'messages.noItemsAvailable': return '没有可用的项目'; + case 'messages.failedToCreatePlayQueue': return '创建播放队列失败'; + case 'messages.failedToCreatePlayQueueNoItems': return '创建播放队列失败 - 没有项目'; + case 'messages.failedPlayback': return ({required Object action, required Object error}) => '无法${action}: ${error}'; case 'profile.noUsersAvailable': return '没有可用用户'; case 'subtitlingStyling.stylingOptions': return '样式选项'; case 'subtitlingStyling.fontSize': return '字号'; @@ -6199,6 +6925,23 @@ extension on _StringsZh { case 'libraries.showLibrary': return '显示媒体库'; case 'libraries.hideLibrary': return '隐藏媒体库'; case 'libraries.libraryOptions': return '媒体库选项'; + case 'libraries.content': return '媒体库内容'; + case 'libraries.selectLibrary': return '选择媒体库'; + case 'libraries.filtersWithCount': return ({required Object count}) => '筛选器(${count})'; + case 'libraries.noRecommendations': return '暂无推荐'; + case 'libraries.noCollections': return '此媒体库中没有合集'; + case 'libraries.noFoldersFound': return '未找到文件夹'; + case 'libraries.folders': return '文件夹'; + case 'libraries.tabs.recommended': return '推荐'; + case 'libraries.tabs.browse': return '浏览'; + case 'libraries.tabs.collections': return '合集'; + case 'libraries.tabs.playlists': return '播放列表'; + case 'libraries.groupings.all': return '全部'; + case 'libraries.groupings.movies': return '电影'; + case 'libraries.groupings.shows': return '剧集'; + case 'libraries.groupings.seasons': return '季'; + case 'libraries.groupings.episodes': return '集'; + case 'libraries.groupings.folders': return '文件夹'; case 'about.title': return '关于'; case 'about.openSourceLicenses': return '开源许可证'; case 'about.versionLabel': return ({required Object version}) => '版本 ${version}'; @@ -6270,6 +7013,31 @@ extension on _StringsZh { case 'playlists.errorAdding': return '添加到播放列表失败'; case 'playlists.errorReordering': return '重新排序播放列表项目失败'; case 'playlists.errorRemoving': return '从播放列表中移除失败'; + case 'playlists.playlist': return '播放列表'; + case 'collections.title': return '合集'; + case 'collections.collection': return '合集'; + case 'collections.empty': return '合集为空'; + case 'collections.noItems': return '此合集没有项目'; + case 'collections.unknownLibrarySection': return '无法删除:未知的媒体库分区'; + case 'collections.deleteCollection': return '删除合集'; + case 'collections.deleteConfirm': return ({required Object title}) => '确定要删除"${title}"吗?此操作无法撤销。'; + case 'collections.deleted': return '已删除合集'; + case 'collections.deleteFailed': return '删除合集失败'; + case 'collections.deleteFailedWithError': return ({required Object error}) => '删除合集失败:${error}'; + case 'collections.failedToLoadItems': return ({required Object error}) => '加载合集项目失败:${error}'; + case 'collections.addTo': return '添加到合集'; + case 'collections.selectCollection': return '选择合集'; + case 'collections.createNewCollection': return '创建新合集'; + case 'collections.collectionName': return '合集名称'; + case 'collections.enterCollectionName': return '输入合集名称'; + case 'collections.addedToCollection': return '已添加到合集'; + case 'collections.errorAddingToCollection': return '添加到合集失败'; + case 'collections.created': return '已创建合集'; + case 'collections.removeFromCollection': return '从合集移除'; + case 'collections.removeFromCollectionConfirm': return ({required Object title}) => '将“${title}”从此合集移除?'; + case 'collections.removedFromCollection': return '已从合集移除'; + case 'collections.removeFromCollectionFailed': return '从合集移除失败'; + case 'collections.removeFromCollectionError': return ({required Object error}) => '从合集移除时出错:${error}'; default: return null; } } diff --git a/lib/i18n/strings.i18n.json b/lib/i18n/strings.i18n.json index e57bc0fe..f2539445 100644 --- a/lib/i18n/strings.i18n.json +++ b/lib/i18n/strings.i18n.json @@ -37,7 +37,10 @@ "refresh": "Refresh", "yes": "Yes", "no": "No", - "server": "Server" + "server": "Server", + "delete": "Delete", + "shuffle": "Shuffle", + "addTo": "Add to..." }, "screens": { "licenses": "Licenses", @@ -233,7 +236,11 @@ "noEpisodesFoundGeneral": "No episodes found", "noResultsFound": "No results found", "sleepTimerSet": "Sleep timer set for ${label}", - "failedToSwitchProfile": "Failed to switch to ${displayName}" + "failedToSwitchProfile": "Failed to switch to ${displayName}", + "noItemsAvailable": "No items available", + "failedToCreatePlayQueue": "Failed to create play queue", + "failedToCreatePlayQueueNoItems": "Failed to create play queue - no items", + "failedPlayback": "Failed to ${action}: ${error}" }, "profile": { "noUsersAvailable": "No users available" @@ -323,7 +330,28 @@ "confirmActionMessage": "Are you sure you want to perform this action?", "showLibrary": "Show library", "hideLibrary": "Hide library", - "libraryOptions": "Library options" + "libraryOptions": "Library options", + "content": "library content", + "selectLibrary": "Select library", + "filtersWithCount": "Filters (${count})", + "noRecommendations": "No recommendations available", + "noCollections": "No collections in this library", + "noFoldersFound": "No folders found", + "folders": "folders", + "tabs": { + "recommended": "Recommended", + "browse": "Browse", + "collections": "Collections", + "playlists": "Playlists" + }, + "groupings": { + "all": "All", + "movies": "Movies", + "shows": "TV Shows", + "seasons": "Seasons", + "episodes": "Episodes", + "folders": "Folders" + } }, "about": { "title": "About", @@ -374,8 +402,35 @@ "libraries": "Libraries", "settings": "Settings" }, + "collections": { + "title": "Collections", + "collection": "Collection", + "empty": "Collection is empty", + "noItems": "No items in this collection", + "unknownLibrarySection": "Cannot delete: Unknown library section", + "deleteCollection": "Delete Collection", + "deleteConfirm": "Are you sure you want to delete \"${title}\"? This action cannot be undone.", + "deleted": "Collection deleted", + "deleteFailed": "Failed to delete collection", + "deleteFailedWithError": "Failed to delete collection: ${error}", + "failedToLoadItems": "Failed to load collection items: ${error}", + "addTo": "Add to collection", + "selectCollection": "Select Collection", + "createNewCollection": "Create New Collection", + "collectionName": "Collection Name", + "enterCollectionName": "Enter collection name", + "addedToCollection": "Added to collection", + "errorAddingToCollection": "Failed to add to collection", + "created": "Collection created", + "removeFromCollection": "Remove from collection", + "removeFromCollectionConfirm": "Remove \"${title}\" from this collection?", + "removedFromCollection": "Removed from collection", + "removeFromCollectionFailed": "Failed to remove from collection", + "removeFromCollectionError": "Error removing from collection: ${error}" + }, "playlists": { "title": "Playlists", + "playlist": "Playlist", "noPlaylists": "No playlists found", "create": "Create Playlist", "newPlaylist": "New Playlist", diff --git a/lib/i18n/strings_de.i18n.json b/lib/i18n/strings_de.i18n.json index e78c5411..2cc35a73 100644 --- a/lib/i18n/strings_de.i18n.json +++ b/lib/i18n/strings_de.i18n.json @@ -37,7 +37,10 @@ "refresh": "Aktualisieren", "yes": "Ja", "no": "Nein", - "server": "Server" + "server": "Server", + "delete": "Löschen", + "shuffle": "Zufall", + "addTo": "Hinzufügen zu..." }, "screens": { "licenses": "Lizenzen", @@ -233,7 +236,11 @@ "noEpisodesFoundGeneral": "Keine Episoden gefunden", "noResultsFound": "Keine Ergebnisse gefunden", "sleepTimerSet": "Sleep-Timer gesetzt auf ${label}", - "failedToSwitchProfile": "Profilwechsel zu ${displayName} fehlgeschlagen" + "failedToSwitchProfile": "Profilwechsel zu ${displayName} fehlgeschlagen", + "noItemsAvailable": "Keine Elemente verfügbar", + "failedToCreatePlayQueue": "Wiedergabewarteschlange konnte nicht erstellt werden", + "failedToCreatePlayQueueNoItems": "Wiedergabewarteschlange konnte nicht erstellt werden – keine Elemente", + "failedPlayback": "Wiedergabe für ${action} fehlgeschlagen: ${error}" }, "profile": { "noUsersAvailable": "Keine Benutzer verfügbar" @@ -323,7 +330,28 @@ "confirmActionMessage": "Aktion wirklich durchführen?", "showLibrary": "Mediathek anzeigen", "hideLibrary": "Mediathek ausblenden", - "libraryOptions": "Mediatheksoptionen" + "libraryOptions": "Mediatheksoptionen", + "content": "Bibliotheksinhalt", + "selectLibrary": "Bibliothek auswählen", + "filtersWithCount": "Filter (${count})", + "noRecommendations": "Keine Empfehlungen verfügbar", + "noCollections": "Keine Sammlungen in dieser Mediathek", + "noFoldersFound": "Keine Ordner gefunden", + "folders": "Ordner", + "tabs": { + "recommended": "Empfohlen", + "browse": "Durchsuchen", + "collections": "Sammlungen", + "playlists": "Wiedergabelisten" + }, + "groupings": { + "all": "Alle", + "movies": "Filme", + "shows": "Serien", + "seasons": "Staffeln", + "episodes": "Episoden", + "folders": "Ordner" + } }, "about": { "title": "Über", @@ -408,6 +436,33 @@ "errorLoading": "Wiedergabelisten konnten nicht geladen werden", "errorAdding": "Konnte nicht zur Wiedergabeliste hinzugefügt werden", "errorReordering": "Element der Wiedergabeliste konnte nicht neu geordnet werden", - "errorRemoving": "Konnte nicht aus der Wiedergabeliste entfernt werden" + "errorRemoving": "Konnte nicht aus der Wiedergabeliste entfernt werden", + "playlist": "Wiedergabeliste" + }, + "collections": { + "title": "Sammlungen", + "collection": "Sammlung", + "empty": "Sammlung ist leer", + "noItems": "Keine Elemente in dieser Sammlung", + "unknownLibrarySection": "Löschen nicht möglich: Unbekannte Bibliothekssektion", + "deleteCollection": "Sammlung löschen", + "deleteConfirm": "Sind Sie sicher, dass Sie \"${title}\" löschen möchten? Dies kann nicht rückgängig gemacht werden.", + "deleted": "Sammlung gelöscht", + "deleteFailed": "Sammlung konnte nicht gelöscht werden", + "deleteFailedWithError": "Sammlung konnte nicht gelöscht werden: ${error}", + "failedToLoadItems": "Sammlungselemente konnten nicht geladen werden: ${error}", + "addTo": "Zur Sammlung hinzufügen", + "selectCollection": "Sammlung auswählen", + "createNewCollection": "Neue Sammlung erstellen", + "collectionName": "Sammlungsname", + "enterCollectionName": "Sammlungsnamen eingeben", + "addedToCollection": "Zur Sammlung hinzugefügt", + "errorAddingToCollection": "Fehler beim Hinzufügen zur Sammlung", + "created": "Sammlung erstellt", + "removeFromCollection": "Aus Sammlung entfernen", + "removeFromCollectionConfirm": "\"${title}\" aus dieser Sammlung entfernen?", + "removedFromCollection": "Aus Sammlung entfernt", + "removeFromCollectionFailed": "Entfernen aus Sammlung fehlgeschlagen", + "removeFromCollectionError": "Fehler beim Entfernen aus der Sammlung: ${error}" } } diff --git a/lib/i18n/strings_it.i18n.json b/lib/i18n/strings_it.i18n.json index 04a05e14..43033fe8 100644 --- a/lib/i18n/strings_it.i18n.json +++ b/lib/i18n/strings_it.i18n.json @@ -37,7 +37,10 @@ "refresh": "Aggiorna", "yes": "Sì", "no": "No", - "server": "Server" + "server": "Server", + "delete": "Elimina", + "shuffle": "Casuale", + "addTo": "Aggiungi a..." }, "screens": { "licenses": "Licenze", @@ -233,7 +236,11 @@ "noEpisodesFoundGeneral": "Nessun episodio trovato", "noResultsFound": "Nessun risultato", "sleepTimerSet": "Imposta timer spegnimento per ${label}", - "failedToSwitchProfile": "Impossibile passare a ${displayName}" + "failedToSwitchProfile": "Impossibile passare a ${displayName}", + "noItemsAvailable": "Nessun elemento disponibile", + "failedToCreatePlayQueue": "Impossibile creare la coda di riproduzione", + "failedToCreatePlayQueueNoItems": "Impossibile creare la coda di riproduzione - nessun elemento", + "failedPlayback": "Impossibile ${action}: ${error}" }, "profile": { "noUsersAvailable": "Nessun utente disponibile" @@ -323,7 +330,28 @@ "confirmActionMessage": "Sei sicuro di voler eseguire questa azione?", "showLibrary": "Mostra libreria", "hideLibrary": "Nascondi libreria", - "libraryOptions": "Opzioni libreria" + "libraryOptions": "Opzioni libreria", + "content": "contenuto della libreria", + "selectLibrary": "Seleziona libreria", + "filtersWithCount": "Filtri (${count})", + "noRecommendations": "Nessun consiglio disponibile", + "noCollections": "Nessuna raccolta in questa libreria", + "noFoldersFound": "Nessuna cartella trovata", + "folders": "cartelle", + "tabs": { + "recommended": "Consigliati", + "browse": "Esplora", + "collections": "Raccolte", + "playlists": "Playlist" + }, + "groupings": { + "all": "Tutti", + "movies": "Film", + "shows": "Serie TV", + "seasons": "Stagioni", + "episodes": "Episodi", + "folders": "Cartelle" + } }, "about": { "title": "Informazioni", @@ -408,6 +436,33 @@ "errorLoading": "Errore durante il caricamento delle playlist", "errorAdding": "Errore durante l'aggiunta alla playlist", "errorReordering": "Errore durante il riordino dell'elemento della playlist", - "errorRemoving": "Errore durante la rimozione dalla playlist" + "errorRemoving": "Errore durante la rimozione dalla playlist", + "playlist": "Playlist" + }, + "collections": { + "title": "Raccolte", + "collection": "Raccolta", + "empty": "La raccolta è vuota", + "noItems": "Nessun elemento in questa raccolta", + "unknownLibrarySection": "Impossibile eliminare: sezione libreria sconosciuta", + "deleteCollection": "Elimina raccolta", + "deleteConfirm": "Sei sicuro di voler eliminare \"${title}\"? Questa azione non può essere annullata.", + "deleted": "Raccolta eliminata", + "deleteFailed": "Impossibile eliminare la raccolta", + "deleteFailedWithError": "Impossibile eliminare la raccolta: ${error}", + "failedToLoadItems": "Impossibile caricare gli elementi della raccolta: ${error}", + "addTo": "Aggiungi alla raccolta", + "selectCollection": "Seleziona raccolta", + "createNewCollection": "Crea nuova raccolta", + "collectionName": "Nome raccolta", + "enterCollectionName": "Inserisci nome raccolta", + "addedToCollection": "Aggiunto alla raccolta", + "errorAddingToCollection": "Errore nell'aggiunta alla raccolta", + "created": "Raccolta creata", + "removeFromCollection": "Rimuovi dalla raccolta", + "removeFromCollectionConfirm": "Rimuovere \"${title}\" da questa raccolta?", + "removedFromCollection": "Rimosso dalla raccolta", + "removeFromCollectionFailed": "Impossibile rimuovere dalla raccolta", + "removeFromCollectionError": "Errore durante la rimozione dalla raccolta: ${error}" } } diff --git a/lib/i18n/strings_nl.i18n.json b/lib/i18n/strings_nl.i18n.json index bced748e..0b7fb138 100644 --- a/lib/i18n/strings_nl.i18n.json +++ b/lib/i18n/strings_nl.i18n.json @@ -37,7 +37,10 @@ "refresh": "Vernieuwen", "yes": "Ja", "no": "Nee", - "server": "Server" + "server": "Server", + "delete": "Verwijderen", + "shuffle": "Shuffle", + "addTo": "Toevoegen aan..." }, "screens": { "licenses": "Licenties", @@ -233,7 +236,11 @@ "noEpisodesFoundGeneral": "Geen afleveringen gevonden", "noResultsFound": "Geen resultaten gevonden", "sleepTimerSet": "Slaap timer ingesteld voor ${label}", - "failedToSwitchProfile": "Kon niet wisselen naar ${displayName}" + "failedToSwitchProfile": "Kon niet wisselen naar ${displayName}", + "noItemsAvailable": "Geen items beschikbaar", + "failedToCreatePlayQueue": "Kan afspeelwachtrij niet maken", + "failedToCreatePlayQueueNoItems": "Kan afspeelwachtrij niet maken - geen items", + "failedPlayback": "Afspelen van ${action} mislukt: ${error}" }, "profile": { "noUsersAvailable": "Geen gebruikers beschikbaar" @@ -323,7 +330,28 @@ "confirmActionMessage": "Weet je zeker dat je deze actie wilt uitvoeren?", "showLibrary": "Toon bibliotheek", "hideLibrary": "Verberg bibliotheek", - "libraryOptions": "Bibliotheek opties" + "libraryOptions": "Bibliotheek opties", + "content": "bibliotheekinhoud", + "selectLibrary": "Bibliotheek kiezen", + "filtersWithCount": "Filters (${count})", + "noRecommendations": "Geen aanbevelingen beschikbaar", + "noCollections": "Geen collecties in deze bibliotheek", + "noFoldersFound": "Geen mappen gevonden", + "folders": "mappen", + "tabs": { + "recommended": "Aanbevolen", + "browse": "Bladeren", + "collections": "Collecties", + "playlists": "Afspeellijsten" + }, + "groupings": { + "all": "Alles", + "movies": "Films", + "shows": "Series", + "seasons": "Seizoenen", + "episodes": "Afleveringen", + "folders": "Mappen" + } }, "about": { "title": "Over", @@ -408,6 +436,33 @@ "errorLoading": "Fout bij laden afspeellijsten", "errorAdding": "Fout bij toevoegen aan afspeellijst", "errorReordering": "Fout bij herschikken van afspeellijstitem", - "errorRemoving": "Fout bij verwijderen uit afspeellijst" + "errorRemoving": "Fout bij verwijderen uit afspeellijst", + "playlist": "Afspeellijst" + }, + "collections": { + "title": "Collecties", + "collection": "Collectie", + "empty": "Collectie is leeg", + "noItems": "Geen items in deze collectie", + "unknownLibrarySection": "Kan niet verwijderen: onbekende bibliotheeksectie", + "deleteCollection": "Collectie verwijderen", + "deleteConfirm": "Weet je zeker dat je \"${title}\" wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.", + "deleted": "Collectie verwijderd", + "deleteFailed": "Collectie verwijderen mislukt", + "deleteFailedWithError": "Collectie verwijderen mislukt: ${error}", + "failedToLoadItems": "Collectie-items laden mislukt: ${error}", + "addTo": "Toevoegen aan collectie", + "selectCollection": "Selecteer collectie", + "createNewCollection": "Nieuwe collectie maken", + "collectionName": "Collectienaam", + "enterCollectionName": "Voer collectienaam in", + "addedToCollection": "Toegevoegd aan collectie", + "errorAddingToCollection": "Fout bij toevoegen aan collectie", + "created": "Collectie gemaakt", + "removeFromCollection": "Verwijderen uit collectie", + "removeFromCollectionConfirm": "\"${title}\" uit deze collectie verwijderen?", + "removedFromCollection": "Uit collectie verwijderd", + "removeFromCollectionFailed": "Verwijderen uit collectie mislukt", + "removeFromCollectionError": "Fout bij verwijderen uit collectie: ${error}" } } diff --git a/lib/i18n/strings_sv.i18n.json b/lib/i18n/strings_sv.i18n.json index b97094d9..2340e6ea 100644 --- a/lib/i18n/strings_sv.i18n.json +++ b/lib/i18n/strings_sv.i18n.json @@ -37,7 +37,10 @@ "refresh": "Uppdatera", "yes": "Ja", "no": "Nej", - "server": "Server" + "server": "Server", + "delete": "Ta bort", + "shuffle": "Blanda", + "addTo": "Lägg till i..." }, "screens": { "licenses": "Licenser", @@ -233,7 +236,11 @@ "noEpisodesFoundGeneral": "Inga avsnitt hittades", "noResultsFound": "Inga resultat hittades", "sleepTimerSet": "Sovtimer inställd för ${label}", - "failedToSwitchProfile": "Misslyckades att byta till ${displayName}" + "failedToSwitchProfile": "Misslyckades att byta till ${displayName}", + "noItemsAvailable": "Inga objekt tillgängliga", + "failedToCreatePlayQueue": "Det gick inte att skapa uppspelningskö", + "failedToCreatePlayQueueNoItems": "Det gick inte att skapa uppspelningskö – inga objekt", + "failedPlayback": "Kunde inte ${action}: ${error}" }, "profile": { "noUsersAvailable": "Inga användare tillgängliga" @@ -323,7 +330,28 @@ "confirmActionMessage": "Är du säker på att du vill utföra denna åtgärd?", "showLibrary": "Visa bibliotek", "hideLibrary": "Dölj bibliotek", - "libraryOptions": "Biblioteksalternativ" + "libraryOptions": "Biblioteksalternativ", + "content": "bibliotekets innehåll", + "selectLibrary": "Välj bibliotek", + "filtersWithCount": "Filter (${count})", + "noRecommendations": "Inga rekommendationer tillgängliga", + "noCollections": "Inga samlingar i det här biblioteket", + "noFoldersFound": "Inga mappar hittades", + "folders": "mappar", + "tabs": { + "recommended": "Rekommenderat", + "browse": "Bläddra", + "collections": "Samlingar", + "playlists": "Spellistor" + }, + "groupings": { + "all": "Alla", + "movies": "Filmer", + "shows": "Serier", + "seasons": "Säsonger", + "episodes": "Avsnitt", + "folders": "Mappar" + } }, "about": { "title": "Om", @@ -408,6 +436,33 @@ "errorLoading": "Det gick inte att ladda spellistor", "errorAdding": "Det gick inte att lägga till i spellista", "errorReordering": "Det gick inte att omordna spellisteobjekt", - "errorRemoving": "Det gick inte att ta bort från spellista" + "errorRemoving": "Det gick inte att ta bort från spellista", + "playlist": "Spellista" + }, + "collections": { + "title": "Samlingar", + "collection": "Samling", + "empty": "Samlingen är tom", + "noItems": "Inga objekt i den här samlingen", + "unknownLibrarySection": "Kan inte ta bort: okänd bibliotekssektion", + "deleteCollection": "Ta bort samling", + "deleteConfirm": "Är du säker på att du vill ta bort \"${title}\"? Detta går inte att ångra.", + "deleted": "Samling borttagen", + "deleteFailed": "Det gick inte att ta bort samlingen", + "deleteFailedWithError": "Det gick inte att ta bort samlingen: ${error}", + "failedToLoadItems": "Det gick inte att läsa in samlingsobjekt: ${error}", + "addTo": "Lägg till i samling", + "selectCollection": "Välj samling", + "createNewCollection": "Skapa ny samling", + "collectionName": "Samlingsnamn", + "enterCollectionName": "Ange samlingsnamn", + "addedToCollection": "Tillagd i samling", + "errorAddingToCollection": "Fel vid tillägg i samling", + "created": "Samling skapad", + "removeFromCollection": "Ta bort från samling", + "removeFromCollectionConfirm": "Ta bort \"${title}\" från denna samling?", + "removedFromCollection": "Borttagen från samling", + "removeFromCollectionFailed": "Misslyckades med att ta bort från samling", + "removeFromCollectionError": "Fel vid borttagning från samling: ${error}" } } diff --git a/lib/i18n/strings_zh.i18n.json b/lib/i18n/strings_zh.i18n.json index 8daa0990..f19a0a78 100644 --- a/lib/i18n/strings_zh.i18n.json +++ b/lib/i18n/strings_zh.i18n.json @@ -37,7 +37,10 @@ "refresh": "刷新", "yes": "是", "no": "否", - "server": "服务器" + "server": "服务器", + "delete": "删除", + "shuffle": "随机播放", + "addTo": "添加到..." }, "screens": { "licenses": "许可证", @@ -233,7 +236,11 @@ "noEpisodesFoundGeneral": "未找到剧集", "noResultsFound": "未找到结果", "sleepTimerSet": "睡眠定时器已设置为 ${label}", - "failedToSwitchProfile": "无法切换到 ${displayName}" + "failedToSwitchProfile": "无法切换到 ${displayName}", + "noItemsAvailable": "没有可用的项目", + "failedToCreatePlayQueue": "创建播放队列失败", + "failedToCreatePlayQueueNoItems": "创建播放队列失败 - 没有项目", + "failedPlayback": "无法${action}: ${error}" }, "profile": { "noUsersAvailable": "没有可用用户" @@ -323,7 +330,28 @@ "confirmActionMessage": "确定要执行此操作吗?", "showLibrary": "显示媒体库", "hideLibrary": "隐藏媒体库", - "libraryOptions": "媒体库选项" + "libraryOptions": "媒体库选项", + "content": "媒体库内容", + "selectLibrary": "选择媒体库", + "filtersWithCount": "筛选器(${count})", + "noRecommendations": "暂无推荐", + "noCollections": "此媒体库中没有合集", + "noFoldersFound": "未找到文件夹", + "folders": "文件夹", + "tabs": { + "recommended": "推荐", + "browse": "浏览", + "collections": "合集", + "playlists": "播放列表" + }, + "groupings": { + "all": "全部", + "movies": "电影", + "shows": "剧集", + "seasons": "季", + "episodes": "集", + "folders": "文件夹" + } }, "about": { "title": "关于", @@ -408,6 +436,33 @@ "errorLoading": "加载播放列表失败", "errorAdding": "添加到播放列表失败", "errorReordering": "重新排序播放列表项目失败", - "errorRemoving": "从播放列表中移除失败" + "errorRemoving": "从播放列表中移除失败", + "playlist": "播放列表" + }, + "collections": { + "title": "合集", + "collection": "合集", + "empty": "合集为空", + "noItems": "此合集没有项目", + "unknownLibrarySection": "无法删除:未知的媒体库分区", + "deleteCollection": "删除合集", + "deleteConfirm": "确定要删除\"${title}\"吗?此操作无法撤销。", + "deleted": "已删除合集", + "deleteFailed": "删除合集失败", + "deleteFailedWithError": "删除合集失败:${error}", + "failedToLoadItems": "加载合集项目失败:${error}", + "addTo": "添加到合集", + "selectCollection": "选择合集", + "createNewCollection": "创建新合集", + "collectionName": "合集名称", + "enterCollectionName": "输入合集名称", + "addedToCollection": "已添加到合集", + "errorAddingToCollection": "添加到合集失败", + "created": "已创建合集", + "removeFromCollection": "从合集移除", + "removeFromCollectionConfirm": "将“${title}”从此合集移除?", + "removedFromCollection": "已从合集移除", + "removeFromCollectionFailed": "从合集移除失败", + "removeFromCollectionError": "从合集移除时出错:${error}" } } diff --git a/lib/models/play_queue_response.dart b/lib/models/play_queue_response.dart new file mode 100644 index 00000000..dbfd0b1a --- /dev/null +++ b/lib/models/play_queue_response.dart @@ -0,0 +1,77 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'plex_metadata.dart'; + +part 'play_queue_response.g.dart'; + +/// Converter to handle both int (0/1) and bool values from Plex API +class BoolOrIntConverter implements JsonConverter { + const BoolOrIntConverter(); + + @override + bool fromJson(Object json) { + if (json is bool) return json; + if (json is int) return json != 0; + if (json is String) return json.toLowerCase() == 'true' || json == '1'; + return false; + } + + @override + Object toJson(bool object) => object; +} + +/// Response from Plex play queue API +/// Contains queue metadata and a window of items +@JsonSerializable(createToJson: false) +class PlayQueueResponse { + final int playQueueID; + final int? playQueueSelectedItemID; + final int? playQueueSelectedItemOffset; + final String? playQueueSelectedMetadataItemID; + @BoolOrIntConverter() + final bool playQueueShuffled; + final String? playQueueSourceURI; + final int? playQueueTotalCount; + final int playQueueVersion; + final int? size; // Number of items in this response window + @JsonKey(name: 'Metadata') + final List? items; + + PlayQueueResponse({ + required this.playQueueID, + this.playQueueSelectedItemID, + this.playQueueSelectedItemOffset, + this.playQueueSelectedMetadataItemID, + required this.playQueueShuffled, + this.playQueueSourceURI, + required this.playQueueTotalCount, + required this.playQueueVersion, + this.size, + this.items, + }); + + factory PlayQueueResponse.fromJson(Map json) { + // The API returns data wrapped in MediaContainer + final container = json['MediaContainer'] as Map? ?? json; + return _$PlayQueueResponseFromJson(container); + } + + /// Get the current selected item from the queue + PlexMetadata? get selectedItem { + if (items == null || playQueueSelectedItemID == null) return null; + try { + return items!.firstWhere( + (item) => item.playQueueItemID == playQueueSelectedItemID, + ); + } catch (e) { + return null; + } + } + + /// Get the index of the selected item in the current window + int? get selectedItemIndex { + if (items == null || playQueueSelectedItemID == null) return null; + return items!.indexWhere( + (item) => item.playQueueItemID == playQueueSelectedItemID, + ); + } +} diff --git a/lib/models/play_queue_response.g.dart b/lib/models/play_queue_response.g.dart new file mode 100644 index 00000000..8f8e3aa9 --- /dev/null +++ b/lib/models/play_queue_response.g.dart @@ -0,0 +1,28 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'play_queue_response.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +PlayQueueResponse _$PlayQueueResponseFromJson(Map json) => + PlayQueueResponse( + playQueueID: (json['playQueueID'] as num).toInt(), + playQueueSelectedItemID: (json['playQueueSelectedItemID'] as num?) + ?.toInt(), + playQueueSelectedItemOffset: (json['playQueueSelectedItemOffset'] as num?) + ?.toInt(), + playQueueSelectedMetadataItemID: + json['playQueueSelectedMetadataItemID'] as String?, + playQueueShuffled: const BoolOrIntConverter().fromJson( + json['playQueueShuffled'] as Object, + ), + playQueueSourceURI: json['playQueueSourceURI'] as String?, + playQueueTotalCount: (json['playQueueTotalCount'] as num?)?.toInt(), + playQueueVersion: (json['playQueueVersion'] as num).toInt(), + size: (json['size'] as num?)?.toInt(), + items: (json['Metadata'] as List?) + ?.map((e) => PlexMetadata.fromJson(e as Map)) + .toList(), + ); diff --git a/lib/models/plex_metadata.dart b/lib/models/plex_metadata.dart index 52233ed1..7274b83c 100644 --- a/lib/models/plex_metadata.dart +++ b/lib/models/plex_metadata.dart @@ -36,11 +36,14 @@ class PlexMetadata { final int? viewCount; final int? leafCount; // Total number of episodes in a series/season final int? viewedLeafCount; // Number of watched episodes in a series/season + final int? childCount; // Number of items in a collection or playlist @JsonKey(name: 'Role') final List? role; // Cast members final String? audioLanguage; // Per-media preferred audio language final String? subtitleLanguage; // Per-media preferred subtitle language final int? playlistItemID; // Playlist item ID (for dumb playlists only) + final int? playQueueItemID; // Play queue item ID (unique even for duplicates) + final int? librarySectionID; // Library section ID this item belongs to // Transient field for clear logo (extracted from Image array) String? _clearLogo; @@ -77,10 +80,13 @@ class PlexMetadata { this.viewCount, this.leafCount, this.viewedLeafCount, + this.childCount, this.role, this.audioLanguage, this.subtitleLanguage, this.playlistItemID, + this.playQueueItemID, + this.librarySectionID, }); /// Create a copy of this metadata with optional field overrides @@ -115,10 +121,13 @@ class PlexMetadata { int? viewCount, int? leafCount, int? viewedLeafCount, + int? childCount, List? role, String? audioLanguage, String? subtitleLanguage, int? playlistItemID, + int? playQueueItemID, + int? librarySectionID, }) { final copy = PlexMetadata( ratingKey: ratingKey ?? this.ratingKey, @@ -151,10 +160,13 @@ class PlexMetadata { viewCount: viewCount ?? this.viewCount, leafCount: leafCount ?? this.leafCount, viewedLeafCount: viewedLeafCount ?? this.viewedLeafCount, + childCount: childCount ?? this.childCount, role: role ?? this.role, audioLanguage: audioLanguage ?? this.audioLanguage, subtitleLanguage: subtitleLanguage ?? this.subtitleLanguage, playlistItemID: playlistItemID ?? this.playlistItemID, + playQueueItemID: playQueueItemID ?? this.playQueueItemID, + librarySectionID: librarySectionID ?? this.librarySectionID, ); // Preserve clearLogo copy._clearLogo = _clearLogo; diff --git a/lib/models/plex_metadata.g.dart b/lib/models/plex_metadata.g.dart index a9f262ea..d5017ef0 100644 --- a/lib/models/plex_metadata.g.dart +++ b/lib/models/plex_metadata.g.dart @@ -37,12 +37,15 @@ PlexMetadata _$PlexMetadataFromJson(Map json) => PlexMetadata( viewCount: (json['viewCount'] as num?)?.toInt(), leafCount: (json['leafCount'] as num?)?.toInt(), viewedLeafCount: (json['viewedLeafCount'] as num?)?.toInt(), + childCount: (json['childCount'] as num?)?.toInt(), role: (json['Role'] as List?) ?.map((e) => PlexRole.fromJson(e as Map)) .toList(), audioLanguage: json['audioLanguage'] as String?, subtitleLanguage: json['subtitleLanguage'] as String?, playlistItemID: (json['playlistItemID'] as num?)?.toInt(), + playQueueItemID: (json['playQueueItemID'] as num?)?.toInt(), + librarySectionID: (json['librarySectionID'] as num?)?.toInt(), ); Map _$PlexMetadataToJson(PlexMetadata instance) => @@ -77,8 +80,11 @@ Map _$PlexMetadataToJson(PlexMetadata instance) => 'viewCount': instance.viewCount, 'leafCount': instance.leafCount, 'viewedLeafCount': instance.viewedLeafCount, + 'childCount': instance.childCount, 'Role': instance.role, 'audioLanguage': instance.audioLanguage, 'subtitleLanguage': instance.subtitleLanguage, 'playlistItemID': instance.playlistItemID, + 'playQueueItemID': instance.playQueueItemID, + 'librarySectionID': instance.librarySectionID, }; diff --git a/lib/providers/playback_state_provider.dart b/lib/providers/playback_state_provider.dart index b994af6c..3b4976df 100644 --- a/lib/providers/playback_state_provider.dart +++ b/lib/providers/playback_state_provider.dart @@ -1,128 +1,287 @@ import 'package:flutter/foundation.dart'; import '../models/plex_metadata.dart'; +import '../models/play_queue_response.dart'; +import '../client/plex_client.dart'; /// Playback mode types enum PlaybackMode { none, // No active playback queue sequential, // Normal episode-to-episode playback (uses Plex API) - shufflePlay, // Shuffle play for shows/seasons - playlist, // Playlist playback (ordered or shuffled) + playQueue, // Play queue-based playback (playlists, collections, shuffle) } -/// Manages playback state for TV shows, seasons, and playlists. +/// Manages playback state using Plex's play queue API. /// This provider is session-only and does not persist across app restarts. class PlaybackStateProvider with ChangeNotifier { - List _queue = []; + // Play queue state + int? _playQueueId; + int _playQueueTotalCount = 0; + bool _playQueueShuffled = false; + int? _currentPlayQueueItemID; + + // Windowed items (loaded around current position) + List _loadedItems = []; + final int _windowSize = 50; // Number of items to keep in memory + + // Legacy state for backward compatibility String? _contextKey; // The show/season/playlist ratingKey for this session - int _currentIndex = 0; PlaybackMode _playbackMode = PlaybackMode.none; + // Client reference for loading more items + PlexClient? _client; + /// Current playback mode PlaybackMode get playbackMode => _playbackMode; /// Whether shuffle mode is currently active - bool get isShuffleActive => _playbackMode == PlaybackMode.shufflePlay; + bool get isShuffleActive => _playQueueShuffled; - /// Whether playlist mode is currently active - bool get isPlaylistActive => _playbackMode == PlaybackMode.playlist; + /// Whether playlist/collection mode is currently active + bool get isPlaylistActive => _playbackMode == PlaybackMode.playQueue; /// Whether any queue-based playback is active bool get isQueueActive => - _queue.isNotEmpty && _playbackMode != PlaybackMode.none; + _playQueueId != null && _playbackMode == PlaybackMode.playQueue; /// The context key (show/season/playlist ratingKey) for the current session String? get shuffleContextKey => _contextKey; - /// Sets a new shuffle queue and starts shuffle mode - void setShuffleQueue(List episodes, String contextKey) { - _queue = List.from(episodes); + /// Current play queue ID + int? get playQueueId => _playQueueId; + + /// Total number of items in the play queue + int get queueLength => _playQueueTotalCount; + + /// Gets the current position in the queue (1-indexed) + int get currentPosition { + if (_currentPlayQueueItemID == null || _loadedItems.isEmpty) return 0; + final index = _loadedItems.indexWhere( + (item) => item.playQueueItemID == _currentPlayQueueItemID, + ); + return index != -1 ? index + 1 : 0; + } + + /// Set the client reference for loading more items + void setClient(PlexClient client) { + _client = client; + } + + /// Update the current play queue item when playing a new item + void setCurrentItem(PlexMetadata metadata) { + if (_playbackMode == PlaybackMode.playQueue && metadata.playQueueItemID != null) { + _currentPlayQueueItemID = metadata.playQueueItemID; + notifyListeners(); + } + } + + /// Initialize playback from a play queue + /// Call this after creating a play queue via the API + Future setPlaybackFromPlayQueue( + PlayQueueResponse playQueue, + String? contextKey, + ) async { + _playQueueId = playQueue.playQueueID; + // Use size or items length as fallback if totalCount is null + _playQueueTotalCount = playQueue.playQueueTotalCount ?? + playQueue.size ?? + (playQueue.items?.length ?? 0); + _playQueueShuffled = playQueue.playQueueShuffled; + _currentPlayQueueItemID = playQueue.playQueueSelectedItemID; + _loadedItems = playQueue.items ?? []; _contextKey = contextKey; - _currentIndex = 0; - _playbackMode = PlaybackMode.shufflePlay; + _playbackMode = PlaybackMode.playQueue; notifyListeners(); } - /// Sets a playback queue for playlist playback (ordered, not shuffled) - void setPlaybackQueue(List items, String contextKey) { - _queue = List.from(items); + /// Legacy method for backward compatibility with shuffle play + /// This now creates a play queue on the server + @Deprecated('Use createPlayQueueFromUri instead') + void setShuffleQueue(List episodes, String contextKey) { + // This is kept for backward compatibility but should not be used + // New code should use the play queue API + _loadedItems = List.from(episodes); _contextKey = contextKey; - _currentIndex = 0; - _playbackMode = PlaybackMode.playlist; + _playbackMode = PlaybackMode.playQueue; notifyListeners(); } + /// Legacy method for backward compatibility with playlist playback + /// This now creates a play queue on the server + @Deprecated('Use createPlayQueueFromUri instead') + void setPlaybackQueue(List items, String contextKey) { + // This is kept for backward compatibility but should not be used + // New code should use the play queue API + _loadedItems = List.from(items); + _contextKey = contextKey; + _playbackMode = PlaybackMode.playQueue; + notifyListeners(); + } + + /// Load more items from the play queue if needed + /// Returns true if more items were loaded + Future _ensureItemsLoaded(int targetPlayQueueItemID) async { + if (_client == null || _playQueueId == null) return false; + + // Check if the target item is already loaded + final hasItem = _loadedItems.any( + (item) => item.playQueueItemID == targetPlayQueueItemID, + ); + + if (hasItem) return true; + + // Load a window around the target item + try { + final response = await _client!.getPlayQueue( + _playQueueId!, + center: targetPlayQueueItemID.toString(), + window: _windowSize, + ); + + if (response != null && response.items != null) { + _loadedItems = response.items!; + // Use size or items length as fallback if totalCount is null + _playQueueTotalCount = response.playQueueTotalCount ?? + response.size ?? + response.items!.length; + _playQueueShuffled = response.playQueueShuffled; + notifyListeners(); + return true; + } + } catch (e) { + // Failed to load items + return false; + } + + return false; + } + /// Gets the next item in the playback queue. /// Returns null if queue is exhausted or current item is not in queue. /// [loopQueue] - If true, restart from beginning when queue is exhausted - PlexMetadata? getNextEpisode( + Future getNextEpisode( String currentItemKey, { bool loopQueue = false, - }) { - if (_queue.isEmpty) return null; + }) async { + if (_playbackMode != PlaybackMode.playQueue) { + // For sequential mode, let the video player handle next episode + return null; + } - // Find current item in queue - final currentIndex = _queue.indexWhere( - (item) => item.ratingKey == currentItemKey, + if (_loadedItems.isEmpty || _currentPlayQueueItemID == null) return null; + + // Find current item in loaded items + final currentIndex = _loadedItems.indexWhere( + (item) => item.playQueueItemID == _currentPlayQueueItemID, ); if (currentIndex == -1) { - // Current item not in queue, clear queue + // Current item not in loaded window, try to load it + final loaded = await _ensureItemsLoaded(_currentPlayQueueItemID!); + if (!loaded) { + clearShuffle(); + return null; + } + // Try again after loading + return getNextEpisode(currentItemKey, loopQueue: loopQueue); + } + + // Check if there's a next item in the loaded window + if (currentIndex + 1 < _loadedItems.length) { + final nextItem = _loadedItems[currentIndex + 1]; + // Don't update _currentPlayQueueItemID here - let setCurrentItem do it when playback starts + return nextItem; + } + + // Check if we're at the end of the entire queue + if (currentIndex + 1 >= _playQueueTotalCount) { + if (loopQueue && _playQueueTotalCount > 0) { + // Loop back to beginning - load first item + if (_client != null && _playQueueId != null) { + final response = await _client!.getPlayQueue(_playQueueId!); + if (response != null && response.items != null && response.items!.isNotEmpty) { + _loadedItems = response.items!; + final firstItem = _loadedItems.first; + // Don't update _currentPlayQueueItemID here - let setCurrentItem do it when playback starts + return firstItem; + } + } + } + // Queue has ended - clear it and let sequential playback take over clearShuffle(); return null; } - // Check if there's a next item - if (currentIndex + 1 >= _queue.length) { - // Queue exhausted - if (loopQueue && _queue.isNotEmpty) { - // Loop back to beginning - _currentIndex = 0; - return _queue[_currentIndex]; + // Need to load next window + if (_client != null && _playQueueId != null) { + // Load next window centered on the item after current + final nextItemID = _loadedItems.last.playQueueItemID; + if (nextItemID != null) { + final loaded = await _ensureItemsLoaded(nextItemID + 1); + if (loaded) { + // Try again with newly loaded items + return getNextEpisode(currentItemKey, loopQueue: loopQueue); + } } - return null; } - _currentIndex = currentIndex + 1; - return _queue[_currentIndex]; + return null; } /// Gets the previous item in the playback queue. /// Returns null if at the beginning of the queue or current item is not in queue. - PlexMetadata? getPreviousEpisode(String currentItemKey) { - if (_queue.isEmpty) return null; + Future getPreviousEpisode(String currentItemKey) async { + if (_playbackMode != PlaybackMode.playQueue) { + // For sequential mode, let the video player handle previous episode + return null; + } - // Find current item in queue - final currentIndex = _queue.indexWhere( - (item) => item.ratingKey == currentItemKey, + if (_loadedItems.isEmpty || _currentPlayQueueItemID == null) return null; + + // Find current item in loaded items + final currentIndex = _loadedItems.indexWhere( + (item) => item.playQueueItemID == _currentPlayQueueItemID, ); if (currentIndex == -1) { - // Current item not in queue + // Current item not in loaded window return null; } - // Check if there's a previous item - if (currentIndex <= 0) { - // At the beginning of queue + // Check if there's a previous item in the loaded window + if (currentIndex > 0) { + final prevItem = _loadedItems[currentIndex - 1]; + // Don't update _currentPlayQueueItemID here - let setCurrentItem do it when playback starts + return prevItem; + } + + // Check if we're at the beginning of the entire queue + if (currentIndex == 0) { return null; } - _currentIndex = currentIndex - 1; - return _queue[_currentIndex]; + // Need to load previous window + if (_client != null && _playQueueId != null) { + final prevItemID = _loadedItems.first.playQueueItemID; + if (prevItemID != null && prevItemID > 0) { + final loaded = await _ensureItemsLoaded(prevItemID - 1); + if (loaded) { + return getPreviousEpisode(currentItemKey); + } + } + } + + return null; } /// Clears the playback queue and exits queue mode void clearShuffle() { - _queue = []; + _playQueueId = null; + _playQueueTotalCount = 0; + _playQueueShuffled = false; + _currentPlayQueueItemID = null; + _loadedItems = []; _contextKey = null; - _currentIndex = 0; _playbackMode = PlaybackMode.none; notifyListeners(); } - - /// Gets the total number of items in the current playback queue - int get queueLength => _queue.length; - - /// Gets the current position in the queue (1-indexed) - int get currentPosition => _currentIndex + 1; } diff --git a/lib/screens/collection_detail_screen.dart b/lib/screens/collection_detail_screen.dart new file mode 100644 index 00000000..18bdc683 --- /dev/null +++ b/lib/screens/collection_detail_screen.dart @@ -0,0 +1,314 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../client/plex_client.dart'; +import '../models/plex_metadata.dart'; +import '../providers/settings_provider.dart'; +import '../services/settings_service.dart'; +import '../utils/provider_extensions.dart'; +import '../utils/app_logger.dart'; +import '../utils/collection_playlist_play_helper.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'; +import '../utils/grid_size_calculator.dart'; + +/// Screen to display the contents of a collection +class CollectionDetailScreen extends StatefulWidget { + final PlexMetadata collection; + + const CollectionDetailScreen({super.key, required this.collection}); + + @override + State createState() => _CollectionDetailScreenState(); +} + +class _CollectionDetailScreenState extends State + with Refreshable, ItemUpdatable { + @override + PlexClient get client => context.clientSafe; + + List _items = []; + bool _isLoading = false; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadCollectionItems(); + } + + Future _loadCollectionItems() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final clientProvider = context.plexClient; + final client = clientProvider.client; + if (client == null) { + throw Exception(t.errors.noClientAvailable); + } + + final items = await client.getCollectionItems(widget.collection.ratingKey); + + setState(() { + _items = items; + _isLoading = false; + }); + + appLogger.d( + 'Loaded ${items.length} items for collection: ${widget.collection.title}', + ); + } catch (e) { + appLogger.e('Failed to load collection items', error: e); + setState(() { + _errorMessage = t.collections.failedToLoadItems( + error: e.toString(), + ); + _isLoading = false; + }); + } + } + + @override + void refresh() { + _loadCollectionItems(); + } + + @override + void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) { + setState(() { + final index = _items.indexWhere((item) => item.ratingKey == ratingKey); + if (index != -1) { + _items[index] = updatedMetadata; + } + }); + } + + Future _playCollection() async { + if (_items.isEmpty) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.collections.empty)), + ); + } + return; + } + + final clientProvider = context.plexClient; + final client = clientProvider.client; + if (client == null) return; + + await playCollectionOrPlaylist( + context: context, + client: client, + item: widget.collection, + shuffle: false, + ); + } + + Future _shufflePlayCollection() async { + if (_items.isEmpty) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.collections.empty)), + ); + } + return; + } + + final clientProvider = context.plexClient; + final client = clientProvider.client; + if (client == null) return; + + await playCollectionOrPlaylist( + context: context, + client: client, + item: widget.collection, + shuffle: true, + ); + } + + Future _deleteCollection() async { + // Get library section ID from the collection or its items + int? sectionId = widget.collection.librarySectionID; + + // If collection doesn't have it, try to get it from loaded items + if (sectionId == null && _items.isNotEmpty) { + sectionId = _items.first.librarySectionID; + } + + if (sectionId == null) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.collections.unknownLibrarySection)), + ); + } + return; + } + + // Show confirmation dialog + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(t.collections.deleteCollection), + content: Text( + t.collections.deleteConfirm(title: widget.collection.title), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(t.common.cancel), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: Text(t.common.delete), + ), + ], + ), + ); + + if (confirmed != true) return; + + try { + final clientProvider = context.plexClient; + final client = clientProvider.client; + if (client == null) return; + + final success = await client.deleteCollection( + sectionId.toString(), + widget.collection.ratingKey, + ); + + if (mounted) { + if (success) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.collections.deleted)), + ); + Navigator.pop(context, true); // Return true to indicate refresh needed + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.collections.deleteFailed)), + ); + } + } + } catch (e) { + appLogger.e('Failed to delete collection', error: e); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + t.collections.deleteFailedWithError(error: e.toString()), + ), + ), + ); + } + } + } + + + @override + Widget build(BuildContext context) { + return Scaffold( + body: CustomScrollView( + slivers: [ + CustomAppBar( + title: Text(widget.collection.title), + pinned: true, + actions: [ + // Play button + if (_items.isNotEmpty) + IconButton( + icon: const Icon(Icons.play_arrow), + tooltip: t.discover.play, + onPressed: _playCollection, + ), + // Shuffle button + if (_items.isNotEmpty) + IconButton( + icon: const Icon(Icons.shuffle), + tooltip: t.common.shuffle, + onPressed: _shufflePlayCollection, + ), + // Delete button + IconButton( + icon: const Icon(Icons.delete), + tooltip: t.common.delete, + onPressed: _deleteCollection, + color: Colors.red, + ), + ], + ), + if (_errorMessage != null) + SliverFillRemaining( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.error_outline, + size: 48, + color: Colors.red, + ), + const SizedBox(height: 16), + Text(_errorMessage!), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadCollectionItems, + child: Text(t.common.retry), + ), + ], + ), + ), + ) + else if (_items.isEmpty && _isLoading) + const SliverFillRemaining( + child: Center(child: CircularProgressIndicator()), + ) + else if (_items.isEmpty) + SliverFillRemaining( + child: Center( + child: Text(t.collections.noItems), + ), + ) + else + SliverPadding( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 8), + sliver: Consumer( + builder: (context, settingsProvider, child) { + return SliverGrid( + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent( + context, + settingsProvider.libraryDensity, + ), + childAspectRatio: 2 / 3.3, + crossAxisSpacing: 0, + mainAxisSpacing: 0, + ), + delegate: SliverChildBuilderDelegate( + (context, index) { + final item = _items[index]; + return MediaCard( + key: Key(item.ratingKey), + item: item, + onRefresh: updateItem, + collectionId: widget.collection.ratingKey, + onListRefresh: _loadCollectionItems, + ); + }, + childCount: _items.length, + ), + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 051e1d79..f794d695 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -12,9 +12,9 @@ import '../widgets/media_card.dart'; import '../widgets/desktop_app_bar.dart'; import '../widgets/user_avatar_widget.dart'; import '../widgets/horizontal_scroll_with_arrows.dart'; +import '../widgets/hub_section.dart'; import 'profile_switch_screen.dart'; import 'server_selection_screen.dart'; -import 'hub_detail_screen.dart'; import '../providers/user_profile_provider.dart'; import '../providers/settings_provider.dart'; import '../mixins/refreshable.dart'; @@ -468,10 +468,7 @@ class _DiscoverScreenState extends State context, listen: false, ); - final plexClientProvider = Provider.of( - context, - listen: false, - ); + final plexClientProvider = context.plexClient; // Clear all user data and provider states await userProfileProvider.logout(); @@ -633,43 +630,14 @@ class _DiscoverScreenState extends State ], // Recommendation Hubs (Trending, Top in Genre, etc.) - for (final hub in _hubs) ...[ + for (final hub in _hubs) SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 24, 16, 8), - child: InkWell( - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => HubDetailScreen(hub: hub), - ), - ); - }, - borderRadius: BorderRadius.circular(8), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - child: Row( - children: [ - Icon(_getHubIcon(hub.title)), - const SizedBox(width: 8), - Text( - hub.title, - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(width: 4), - const Icon(Icons.chevron_right, size: 20), - ], - ), - ), - ), + child: HubSection( + hub: hub, + icon: _getHubIcon(hub.title), + onRefresh: updateItem, ), ), - _buildHorizontalList(hub.items, isLarge: false), - ], if (_onDeck.isEmpty && _hubs.isEmpty) SliverFillRemaining( diff --git a/lib/screens/libraries_screen.dart b/lib/screens/libraries_screen.dart index ff02bde8..532bed37 100644 --- a/lib/screens/libraries_screen.dart +++ b/lib/screens/libraries_screen.dart @@ -15,13 +15,17 @@ import '../widgets/media_card.dart'; import '../widgets/desktop_app_bar.dart'; import '../widgets/app_bar_back_button.dart'; import '../widgets/context_menu_wrapper.dart'; +import '../widgets/filters_bottom_sheet.dart'; +import '../widgets/sort_bottom_sheet.dart'; import '../services/storage_service.dart'; -import '../services/settings_service.dart'; import '../mixins/refreshable.dart'; import '../mixins/item_updatable.dart'; import '../theme/theme_helper.dart'; import '../i18n/strings.g.dart'; -import 'playlists_screen.dart'; +import 'library_tabs/library_browse_tab.dart'; +import 'library_tabs/library_recommended_tab.dart'; +import 'library_tabs/library_collections_tab.dart'; +import 'library_tabs/library_playlists_tab.dart'; class LibrariesScreen extends StatefulWidget { const LibrariesScreen({super.key}); @@ -31,24 +35,33 @@ class LibrariesScreen extends StatefulWidget { } class _LibrariesScreenState extends State - with Refreshable, ItemUpdatable { + with Refreshable, ItemUpdatable, SingleTickerProviderStateMixin { @override PlexClient get client => context.clientSafe; + late TabController _tabController; + + // GlobalKeys for tabs to enable refresh + final _recommendedTabKey = GlobalKey>(); + final _browseTabKey = GlobalKey>(); + final _collectionsTabKey = GlobalKey>(); + final _playlistsTabKey = GlobalKey>(); + List _allLibraries = []; // All libraries from API (unfiltered) - List _items = []; - List _filters = []; - List _sortOptions = []; bool _isLoadingLibraries = true; - bool _isLoadingItems = false; String? _errorMessage; String? _selectedLibraryKey; + bool _isInitialLoad = true; + + // Legacy fields kept for compatibility with existing methods + // TODO: Clean up after refactoring _loadLibraryContent + List _filters = []; + List _sortOptions = []; Map _selectedFilters = {}; PlexSort? _selectedSort; bool _isSortDescending = false; - bool _isInitialLoad = true; - - // Pagination state + bool _isLoadingItems = false; + List _items = []; int _currentPage = 0; bool _hasMoreItems = true; CancelToken? _cancelToken; @@ -58,9 +71,30 @@ class _LibrariesScreenState extends State @override void initState() { super.initState(); + _tabController = TabController(length: 4, vsync: this); + _tabController.addListener(_onTabChanged); _loadLibraries(); } + void _onTabChanged() { + // Save tab index when changed + if (_selectedLibraryKey != null && !_tabController.indexIsChanging) { + StorageService.getInstance().then((storage) { + storage.saveLibraryTab(_selectedLibraryKey!, _tabController.index); + }); + } + // Rebuild to update chip selection state + setState(() {}); + } + + @override + void dispose() { + _tabController.removeListener(_onTabChanged); + _tabController.dispose(); + _cancelToken?.cancel(); + super.dispose(); + } + /// Helper method to get user-friendly error message from exception String _getErrorMessage(dynamic error, String context) { if (error is DioException) { @@ -87,10 +121,7 @@ class _LibrariesScreenState extends State Future _loadLibraries() async { // Extract context dependencies before async gap - final clientProvider = Provider.of( - context, - listen: false, - ); + final clientProvider = context.plexClient; final hiddenLibrariesProvider = Provider.of( context, listen: false, @@ -138,7 +169,6 @@ class _LibrariesScreenState extends State // Load saved preferences final savedLibraryKey = storage.getSelectedLibraryKey(); - final savedFilters = storage.getLibraryFilters(); // Find the library by key in visible libraries String? libraryKeyToLoad; @@ -157,12 +187,12 @@ class _LibrariesScreenState extends State libraryKeyToLoad = visibleLibraries.first.key; } - // Restore filters BEFORE loading content - if (savedFilters.isNotEmpty) { - _selectedFilters = Map.from(savedFilters); - } - if (libraryKeyToLoad != null) { + final savedFilters = + storage.getLibraryFilters(sectionId: libraryKeyToLoad); + if (savedFilters.isNotEmpty) { + _selectedFilters = Map.from(savedFilters); + } _loadLibraryContent(libraryKeyToLoad); } } @@ -259,13 +289,24 @@ class _LibrariesScreenState extends State _isInitialLoad = false; } - // Save selected library key + // Save selected library key and restore saved tab final storage = await StorageService.getInstance(); await storage.saveSelectedLibraryKey(libraryKey); + // Restore saved tab index for this library + final savedTabIndex = storage.getLibraryTab(libraryKey); + if (savedTabIndex != null && savedTabIndex >= 0 && savedTabIndex < 4) { + setState(() { + _tabController.index = savedTabIndex; + }); + } + // Clear filters in storage when changing library if (isChangingLibrary) { - await storage.saveLibraryFilters({}); + await storage.saveLibraryFilters( + {}, + sectionId: libraryKey, + ); } // Cancel any existing requests @@ -363,10 +404,7 @@ class _LibrariesScreenState extends State Future _loadFilters(String libraryKey) async { try { - final clientProvider = Provider.of( - context, - listen: false, - ); + final clientProvider = context.plexClient; final client = clientProvider.client; if (client == null) { throw Exception(t.errors.noClientAvailable); @@ -386,10 +424,7 @@ class _LibrariesScreenState extends State Future _loadSortOptions(String libraryKey) async { try { - final clientProvider = Provider.of( - context, - listen: false, - ); + final clientProvider = context.plexClient; final client = clientProvider.client; if (client == null) { throw Exception(t.errors.noClientAvailable); @@ -399,24 +434,25 @@ class _LibrariesScreenState extends State // Load saved sort preference for this library final storage = await StorageService.getInstance(); - final savedSortKey = storage.getLibrarySort(libraryKey); + final savedSortData = storage.getLibrarySort(libraryKey); // Find the saved sort in the options PlexSort? savedSort; bool descending = false; - if (savedSortKey.endsWith(':desc')) { - descending = true; - final baseKey = savedSortKey.replaceAll(':desc', ''); - savedSort = sortOptions.firstWhere( - (s) => s.key == baseKey, - orElse: () => sortOptions.first, - ); + if (savedSortData != null) { + final sortKey = savedSortData['key'] as String?; + if (sortKey != null) { + savedSort = sortOptions.firstWhere( + (s) => s.key == sortKey, + orElse: () => sortOptions.first, + ); + descending = (savedSortData['descending'] as bool?) ?? false; + } else { + savedSort = sortOptions.first; + } } else { - savedSort = sortOptions.firstWhere( - (s) => s.key == savedSortKey, - orElse: () => sortOptions.first, - ); + savedSort = sortOptions.first; } setState(() { @@ -448,10 +484,7 @@ class _LibrariesScreenState extends State }); try { - final clientProvider = Provider.of( - context, - listen: false, - ); + final clientProvider = context.plexClient; final client = clientProvider.client; if (client == null) { throw Exception(t.errors.noClientAvailable); @@ -493,8 +526,11 @@ class _LibrariesScreenState extends State // Save sort preference for this library final storage = await StorageService.getInstance(); - final sortKey = sort.getSortKey(descending: descending); - await storage.saveLibrarySort(_selectedLibraryKey!, sortKey); + await storage.saveLibrarySort( + _selectedLibraryKey!, + sort.key, + descending: descending, + ); // Reload content with new sort _applyFilters(); @@ -514,6 +550,36 @@ class _LibrariesScreenState extends State _loadLibraries(); } + // Refresh the currently active tab + void _refreshCurrentTab() { + switch (_tabController.index) { + case 0: // Recommended tab + final state = _recommendedTabKey.currentState; + if (state is Refreshable) { + (state as Refreshable).refresh(); + } + break; + case 1: // Browse tab + final state = _browseTabKey.currentState; + if (state is Refreshable) { + (state as Refreshable).refresh(); + } + break; + case 2: // Collections tab + final state = _collectionsTabKey.currentState; + if (state is Refreshable) { + (state as Refreshable).refresh(); + } + break; + case 3: // Playlists tab + final state = _playlistsTabKey.currentState; + if (state is Refreshable) { + (state as Refreshable).refresh(); + } + break; + } + } + // Public method to fully reload all content (for profile switches) void fullRefresh() { appLogger.d('LibrariesScreen.fullRefresh() called - reloading all content'); @@ -558,45 +624,6 @@ class _LibrariesScreenState extends State } } - void _showFiltersBottomSheet() { - showModalBottomSheet( - context: context, - isScrollControlled: true, - builder: (context) => _FiltersBottomSheet( - filters: _filters, - selectedFilters: _selectedFilters, - onFiltersChanged: (filters) async { - setState(() { - _selectedFilters.clear(); - _selectedFilters.addAll(filters); - }); - - // Save filters to storage - final storage = await StorageService.getInstance(); - await storage.saveLibraryFilters(filters); - - _applyFilters(); - }, - ), - ); - } - - void _showSortBottomSheet() { - showModalBottomSheet( - context: context, - isScrollControlled: true, - builder: (context) => _SortBottomSheet( - sortOptions: _sortOptions, - selectedSort: _selectedSort, - isSortDescending: _isSortDescending, - onSortChanged: (sort, descending) { - Navigator.pop(context); - _applySort(sort, descending); - }, - ), - ); - } - List _getLibraryMenuItems(PlexLibrary library) { return [ ContextMenuItem( @@ -858,6 +885,88 @@ class _LibrariesScreenState extends State } } + Widget _buildTabChip(String label, int index) { + final isSelected = _tabController.index == index; + final t = tokens(context); + + return ChoiceChip( + label: Text(label), + selected: isSelected, + onSelected: (selected) { + if (selected) { + setState(() { + _tabController.index = index; + }); + } + }, + backgroundColor: t.surface, + selectedColor: t.text, + side: BorderSide(color: t.outline), + labelStyle: TextStyle( + color: isSelected ? t.bg : t.text, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, + ), + showCheckmark: false, + ); + } + + Widget _buildLibraryDropdownTitle(List visibleLibraries) { + final selectedLibrary = visibleLibraries.firstWhere( + (lib) => lib.key == _selectedLibraryKey, + orElse: () => visibleLibraries.first, + ); + + return PopupMenuButton( + offset: const Offset(0, 48), + tooltip: t.libraries.selectLibrary, + onSelected: (libraryKey) { + _loadLibraryContent(libraryKey); + }, + itemBuilder: (context) { + return visibleLibraries.map((library) { + final isSelected = library.key == _selectedLibraryKey; + return PopupMenuItem( + value: library.key, + child: Row( + children: [ + Icon( + _getLibraryIcon(library.type), + size: 20, + color: isSelected + ? Theme.of(context).colorScheme.primary + : null, + ), + const SizedBox(width: 12), + Text( + library.title, + style: TextStyle( + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, + color: isSelected + ? Theme.of(context).colorScheme.primary + : null, + ), + ), + ], + ), + ); + }).toList(); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(_getLibraryIcon(selectedLibrary.type), size: 20), + const SizedBox(width: 8), + Text( + selectedLibrary.title, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(width: 4), + const Icon(Icons.arrow_drop_down, size: 24), + ], + ), + ); + } + @override Widget build(BuildContext context) { // Watch for hidden libraries changes to trigger rebuild @@ -873,7 +982,9 @@ class _LibrariesScreenState extends State body: CustomScrollView( slivers: [ DesktopSliverAppBar( - title: Text(t.libraries.title), + title: visibleLibraries.isNotEmpty && _selectedLibraryKey != null + ? _buildLibraryDropdownTitle(visibleLibraries) + : Text(t.libraries.title), floating: true, pinned: true, backgroundColor: Theme.of(context).scaffoldBackgroundColor, @@ -889,26 +1000,9 @@ class _LibrariesScreenState extends State ), onPressed: _showLibraryManagementSheet, ), - if (_sortOptions.isNotEmpty) - IconButton( - icon: Icon(Icons.swap_vert, semanticLabel: t.libraries.sort), - onPressed: _showSortBottomSheet, - ), - if (_filters.isNotEmpty) - IconButton( - icon: Badge( - label: Text('${_selectedFilters.length}'), - isLabelVisible: _selectedFilters.isNotEmpty, - child: Icon( - Icons.filter_list, - semanticLabel: t.libraries.filters, - ), - ), - onPressed: _showFiltersBottomSheet, - ), IconButton( icon: Icon(Icons.refresh, semanticLabel: t.common.refresh), - onPressed: () => _loadLibraryContent(_selectedLibraryKey!), + onPressed: _refreshCurrentTab, ), ], ), @@ -956,186 +1050,79 @@ class _LibrariesScreenState extends State ), ) else ...[ - // Library selector chips - SliverToBoxAdapter( - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: List.generate(visibleLibraries.length, (index) { - final library = visibleLibraries[index]; - final isSelected = library.key == _selectedLibraryKey; - final t = tokens(context); - return Padding( - padding: const EdgeInsets.only(right: 8), - child: ContextMenuWrapper( - menuItems: _getLibraryMenuItems(library), - onMenuItemSelected: (value) => - _handleLibraryMenuAction(value, library), - onTap: () => _loadLibraryContent(library.key), - child: ChoiceChip( - label: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - _getLibraryIcon(library.type), - size: 16, - color: isSelected ? t.bg : t.text, - ), - const SizedBox(width: 6), - Text(library.title), - ], - ), - selected: isSelected, - onSelected: (selected) { - if (selected) { - _loadLibraryContent(library.key); - } - }, - backgroundColor: t.surface, - selectedColor: t.text, - side: BorderSide(color: t.outline), - labelStyle: TextStyle( - color: isSelected ? t.bg : t.text, - fontWeight: isSelected - ? FontWeight.w600 - : FontWeight.w400, - ), - showCheckmark: false, - ), - ), - ); - }), + // Tab selector chips + if (_selectedLibraryKey != null) + SliverToBoxAdapter( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, ), - ), - ), - ), - - // Content grid - if (_isLoadingItems && _items.isEmpty) - const SliverFillRemaining( - child: Center(child: CircularProgressIndicator()), - ) - else if (_errorMessage != null) - SliverFillRemaining( - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - Icons.error_outline, - size: 48, - color: Colors.red, - ), - const SizedBox(height: 16), - Text(_errorMessage!), - const SizedBox(height: 16), - ElevatedButton( - onPressed: () => - _loadLibraryContent(_selectedLibraryKey!), - child: Text(t.common.retry), - ), - ], - ), - ), - ) - else if (_items.isEmpty) - SliverFillRemaining( - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - Icons.folder_open, - size: 64, - color: Colors.grey, - ), - const SizedBox(height: 16), - Text(t.libraries.thisLibraryIsEmpty), - ], - ), - ), - ) - else ...[ - Consumer( - builder: (context, settingsProvider, child) { - if (settingsProvider.viewMode == ViewMode.list) { - return SliverPadding( - padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), - sliver: SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - final item = _items[index]; - return MediaCard( - key: Key(item.ratingKey), - item: item, - onRefresh: updateItem, - ); - }, childCount: _items.length), - ), - ); - } else { - return SliverPadding( - padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), - sliver: SliverGrid( - gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: _getMaxCrossAxisExtent( - context, - settingsProvider.libraryDensity, - ), - childAspectRatio: 2 / 3.3, - crossAxisSpacing: 0, - mainAxisSpacing: 0, - ), - delegate: SliverChildBuilderDelegate((context, index) { - final item = _items[index]; - return MediaCard( - key: Key(item.ratingKey), - item: item, - onRefresh: updateItem, - ); - }, childCount: _items.length), - ), - ); - } - }, - ), - // Show loading indicator if there are more items to load - if (_hasMoreItems && _isLoadingItems) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( children: [ - const CircularProgressIndicator(), - const SizedBox(height: 8), - Text( - t.libraries.loadingLibraryWithCount( - count: _items.length, - ), - style: Theme.of(context).textTheme.bodySmall, + _buildTabChip( + t.libraries.tabs.recommended, + 0, + ), + const SizedBox(width: 8), + _buildTabChip( + t.libraries.tabs.browse, + 1, + ), + const SizedBox(width: 8), + _buildTabChip( + t.libraries.tabs.collections, + 2, + ), + const SizedBox(width: 8), + _buildTabChip( + t.libraries.tabs.playlists, + 3, ), ], ), ), ), - ], + ), + + // Tab content + if (_selectedLibraryKey != null) + SliverFillRemaining( + child: TabBarView( + controller: _tabController, + children: [ + LibraryRecommendedTab( + key: _recommendedTabKey, + library: _allLibraries.firstWhere( + (lib) => lib.key == _selectedLibraryKey, + ), + ), + LibraryBrowseTab( + key: _browseTabKey, + library: _allLibraries.firstWhere( + (lib) => lib.key == _selectedLibraryKey, + ), + ), + LibraryCollectionsTab( + key: _collectionsTabKey, + library: _allLibraries.firstWhere( + (lib) => lib.key == _selectedLibraryKey, + ), + ), + LibraryPlaylistsTab( + key: _playlistsTabKey, + library: _allLibraries.firstWhere( + (lib) => lib.key == _selectedLibraryKey, + ), + ), + ], + ), + ), ], ], ), - floatingActionButton: FloatingActionButton( - onPressed: () { - Navigator.push( - context, - MaterialPageRoute(builder: (context) => const PlaylistsScreen()), - ); - }, - tooltip: t.playlists.title, - child: const Icon(Icons.playlist_play), - ), ); } @@ -1153,513 +1140,6 @@ class _LibrariesScreenState extends State return Icons.folder; } } - - double _getMaxCrossAxisExtent(BuildContext context, LibraryDensity density) { - final screenWidth = MediaQuery.of(context).size.width; - final padding = 16.0; // 8px left + 8px right - final availableWidth = screenWidth - padding; - - if (screenWidth >= 900) { - // Wide screens (desktop/large tablet landscape): Responsive division - double divisor; - double maxItemWidth; - - switch (density) { - case LibraryDensity.comfortable: - divisor = 6.5; - maxItemWidth = 280; - break; - case LibraryDensity.normal: - divisor = 8.0; - maxItemWidth = 200; - break; - case LibraryDensity.compact: - divisor = 10.0; - maxItemWidth = 160; - break; - } - - return (availableWidth / divisor).clamp(0, maxItemWidth); - } else if (screenWidth >= 600) { - // Medium screens (tablets): Fixed 4-5-6 items - int targetItemCount = switch (density) { - LibraryDensity.comfortable => 4, - LibraryDensity.normal => 5, - LibraryDensity.compact => 6, - }; - return availableWidth / targetItemCount; - } else { - // Small screens (phones): Fixed 2-3-4 items - int targetItemCount = switch (density) { - LibraryDensity.comfortable => 2, - LibraryDensity.normal => 3, - LibraryDensity.compact => 4, - }; - return availableWidth / targetItemCount; - } - } -} - -class _FiltersBottomSheet extends StatefulWidget { - final List filters; - final Map selectedFilters; - final Function(Map) onFiltersChanged; - - const _FiltersBottomSheet({ - required this.filters, - required this.selectedFilters, - required this.onFiltersChanged, - }); - - @override - State<_FiltersBottomSheet> createState() => _FiltersBottomSheetState(); -} - -class _FiltersBottomSheetState extends State<_FiltersBottomSheet> { - PlexFilter? _currentFilter; - List _filterValues = []; - bool _isLoadingValues = false; - final Map _tempSelectedFilters = {}; - final Map _filterDisplayNames = {}; // Cache for display names - late List _sortedFilters; - - @override - void initState() { - super.initState(); - _tempSelectedFilters.addAll(widget.selectedFilters); - _sortFilters(); - } - - void _sortFilters() { - // Separate boolean filters (toggles) from regular filters - final booleanFilters = widget.filters - .where((f) => f.filterType == 'boolean') - .toList(); - final regularFilters = widget.filters - .where((f) => f.filterType != 'boolean') - .toList(); - - // Combine with boolean filters first - _sortedFilters = [...booleanFilters, ...regularFilters]; - } - - bool _isBooleanFilter(PlexFilter filter) { - return filter.filterType == 'boolean'; - } - - Future _loadFilterValues(PlexFilter filter) async { - setState(() { - _currentFilter = filter; - _isLoadingValues = true; - }); - - try { - final clientProvider = Provider.of( - context, - listen: false, - ); - final client = clientProvider.client; - if (client == null) { - throw Exception(t.errors.noClientAvailable); - } - - final values = await client.getFilterValues(filter.key); - setState(() { - _filterValues = values; - _isLoadingValues = false; - }); - } catch (e) { - setState(() { - _filterValues = []; - _isLoadingValues = false; - }); - } - } - - void _goBack() { - setState(() { - _currentFilter = null; - _filterValues = []; - }); - } - - void _applyFilters() { - widget.onFiltersChanged(_tempSelectedFilters); - Navigator.pop(context); - } - - String _extractFilterValue(String key, String filterName) { - if (key.contains('?')) { - final queryStart = key.indexOf('?'); - final queryString = key.substring(queryStart + 1); - final params = Uri.splitQueryString(queryString); - return params[filterName] ?? key; - } else if (key.startsWith('/')) { - return key.split('/').last; - } - return key; - } - - @override - Widget build(BuildContext context) { - return DraggableScrollableSheet( - initialChildSize: 0.7, - minChildSize: 0.5, - maxChildSize: 0.95, - expand: false, - builder: (context, scrollController) { - if (_currentFilter != null) { - // Show filter options view - return Column( - children: [ - // Header with back button - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - border: Border( - bottom: BorderSide(color: Theme.of(context).dividerColor), - ), - ), - child: Row( - children: [ - AppBarBackButton( - style: BackButtonStyle.plain, - onPressed: _goBack, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - _currentFilter!.title, - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - ), - IconButton( - icon: const Icon(Icons.close), - onPressed: () => Navigator.pop(context), - ), - ], - ), - ), - - // Filter options list - if (_isLoadingValues) - const Expanded( - child: Center(child: CircularProgressIndicator()), - ) - else - Expanded( - child: ListView.builder( - controller: scrollController, - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: _filterValues.length + 1, - itemBuilder: (context, index) { - if (index == 0) { - final isSelected = !_tempSelectedFilters.containsKey( - _currentFilter!.filter, - ); - return ListTile( - title: Text(t.libraries.all), - selected: isSelected, - onTap: () { - setState(() { - _tempSelectedFilters.remove( - _currentFilter!.filter, - ); - }); - _applyFilters(); - }, - ); - } - - final value = _filterValues[index - 1]; - final filterValue = _extractFilterValue( - value.key, - _currentFilter!.filter, - ); - final isSelected = - _tempSelectedFilters[_currentFilter!.filter] == - filterValue; - - return ListTile( - title: Text(value.title), - selected: isSelected, - onTap: () { - setState(() { - _tempSelectedFilters[_currentFilter!.filter] = - filterValue; - // Cache the display name for this filter value - _filterDisplayNames['${_currentFilter!.filter}:$filterValue'] = - value.title; - }); - _applyFilters(); - }, - ); - }, - ), - ), - ], - ); - } - - // Show main filters view - return Column( - children: [ - // Header - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - border: Border( - bottom: BorderSide(color: Theme.of(context).dividerColor), - ), - ), - child: Row( - children: [ - const Icon(Icons.filter_list), - const SizedBox(width: 12), - Text( - t.libraries.filters, - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - const Spacer(), - if (_tempSelectedFilters.isNotEmpty) - TextButton.icon( - onPressed: () { - setState(() { - _tempSelectedFilters.clear(); - }); - _applyFilters(); - }, - icon: const Icon(Icons.clear_all), - label: Text(t.libraries.clearAll), - ), - IconButton( - icon: const Icon(Icons.close), - onPressed: () => Navigator.pop(context), - ), - ], - ), - ), - - // All Filters (boolean toggles first, then regular filters) - Expanded( - child: ListView.builder( - controller: scrollController, - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: _sortedFilters.length, - itemBuilder: (context, index) { - final filter = _sortedFilters[index]; - - // Handle boolean filters as switches (unwatched, inProgress, unmatched, hdr, etc.) - if (_isBooleanFilter(filter)) { - final isActive = - _tempSelectedFilters.containsKey(filter.filter) && - _tempSelectedFilters[filter.filter] == '1'; - return SwitchListTile( - value: isActive, - onChanged: (value) { - setState(() { - if (value) { - _tempSelectedFilters[filter.filter] = '1'; - } else { - _tempSelectedFilters.remove(filter.filter); - } - }); - _applyFilters(); - }, - title: Text(filter.title), - ); - } - - // Regular navigable filters - show selected value instead of checkmark - final selectedValue = _tempSelectedFilters[filter.filter]; - String? displayValue; - if (selectedValue != null) { - // Try to get the cached display name, fall back to the value itself - displayValue = - _filterDisplayNames['${filter.filter}:$selectedValue'] ?? - selectedValue; - } - - return ListTile( - title: Text(filter.title), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (displayValue != null) - Flexible( - child: Text( - displayValue, - style: TextStyle( - color: Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.w500, - ), - overflow: TextOverflow.ellipsis, - ), - ), - if (displayValue != null) const SizedBox(width: 8), - const Icon(Icons.chevron_right), - ], - ), - onTap: () => _loadFilterValues(filter), - ); - }, - ), - ), - ], - ); - }, - ); - } -} - -class _SortBottomSheet extends StatefulWidget { - final List sortOptions; - final PlexSort? selectedSort; - final bool isSortDescending; - final Function(PlexSort, bool) onSortChanged; - - const _SortBottomSheet({ - required this.sortOptions, - required this.selectedSort, - required this.isSortDescending, - required this.onSortChanged, - }); - - @override - State<_SortBottomSheet> createState() => _SortBottomSheetState(); -} - -class _SortBottomSheetState extends State<_SortBottomSheet> { - late PlexSort? _tempSelectedSort; - late bool _tempDescending; - - @override - void initState() { - super.initState(); - _tempSelectedSort = widget.selectedSort; - _tempDescending = widget.isSortDescending; - } - - @override - Widget build(BuildContext context) { - return DraggableScrollableSheet( - initialChildSize: 0.6, - minChildSize: 0.4, - maxChildSize: 0.9, - expand: false, - builder: (context, scrollController) { - return Column( - children: [ - // Header - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - border: Border( - bottom: BorderSide(color: Theme.of(context).dividerColor), - ), - ), - child: Row( - children: [ - Expanded( - child: Text( - t.libraries.sortBy, - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - ), - IconButton( - icon: const Icon(Icons.close), - onPressed: () => Navigator.pop(context), - ), - ], - ), - ), - - // Sort options list - Expanded( - child: RadioGroup( - groupValue: _tempSelectedSort?.key, - onChanged: (value) { - final sort = widget.sortOptions.firstWhere( - (s) => s.key == value, - ); - setState(() { - _tempSelectedSort = sort; - // Use default direction for newly selected sort - _tempDescending = sort.isDefaultDescending; - }); - // Apply sort immediately with default direction - widget.onSortChanged(sort, sort.isDefaultDescending); - }, - child: ListView.builder( - controller: scrollController, - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: widget.sortOptions.length, - itemBuilder: (context, index) { - final sort = widget.sortOptions[index]; - final isSelected = _tempSelectedSort?.key == sort.key; - - return ListTile( - title: Text(sort.title), - trailing: isSelected - ? Row( - mainAxisSize: MainAxisSize.min, - children: [ - // Direction toggle buttons - SegmentedButton( - showSelectedIcon: false, - segments: const [ - ButtonSegment( - value: false, - icon: Icon(Icons.arrow_upward, size: 16), - ), - ButtonSegment( - value: true, - icon: Icon( - Icons.arrow_downward, - size: 16, - ), - ), - ], - selected: {_tempDescending}, - onSelectionChanged: (Set selected) { - widget.onSortChanged(sort, selected.first); - }, - ), - ], - ) - : null, - leading: Radio( - value: sort.key, - toggleable: false, - ), - onTap: () { - setState(() { - _tempSelectedSort = sort; - // Use default direction for newly selected sort - _tempDescending = sort.isDefaultDescending; - }); - // Apply sort immediately with default direction - widget.onSortChanged(sort, sort.isDefaultDescending); - }, - ); - }, - ), - ), - ), - ], - ); - }, - ); - } } class _LibraryManagementSheet extends StatefulWidget { diff --git a/lib/screens/library_tabs/library_browse_tab.dart b/lib/screens/library_tabs/library_browse_tab.dart new file mode 100644 index 00000000..b2234110 --- /dev/null +++ b/lib/screens/library_tabs/library_browse_tab.dart @@ -0,0 +1,602 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:dio/dio.dart'; +import '../../client/plex_client.dart'; +import '../../models/plex_library.dart'; +import '../../models/plex_metadata.dart'; +import '../../models/plex_filter.dart'; +import '../../models/plex_sort.dart'; +import '../../providers/plex_client_provider.dart'; +import '../../providers/settings_provider.dart'; +import '../../utils/provider_extensions.dart'; +import '../../utils/app_logger.dart'; +import '../../utils/grid_size_calculator.dart'; +import '../../widgets/media_card.dart'; +import '../../widgets/app_bar_back_button.dart'; +import '../../widgets/folder_tree_view.dart'; +import '../../widgets/filters_bottom_sheet.dart'; +import '../../widgets/sort_bottom_sheet.dart'; +import '../../services/storage_service.dart'; +import '../../services/settings_service.dart' show LibraryDensity, ViewMode; +import '../../mixins/item_updatable.dart'; +import '../../mixins/refreshable.dart'; +import '../../i18n/strings.g.dart'; + +/// Browse tab for library screen +/// Shows library items with grouping, filtering, and sorting +class LibraryBrowseTab extends StatefulWidget { + final PlexLibrary library; + final String? viewMode; + final String? density; + + const LibraryBrowseTab({ + super.key, + required this.library, + this.viewMode, + this.density, + }); + + @override + State createState() => _LibraryBrowseTabState(); +} + +class _LibraryBrowseTabState extends State + with AutomaticKeepAliveClientMixin, ItemUpdatable, Refreshable { + @override + bool get wantKeepAlive => true; + + @override + PlexClient get client => context.clientSafe; + + @override + void refresh() { + _loadContent(); + } + + @override + void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) { + setState(() { + final index = _items.indexWhere((item) => item.ratingKey == ratingKey); + if (index != -1) { + _items[index] = updatedMetadata; + } + }); + } + + List _items = []; + List _filters = []; + List _sortOptions = []; + bool _isLoading = false; + String? _errorMessage; + Map _selectedFilters = {}; + PlexSort? _selectedSort; + bool _isSortDescending = false; + String _selectedGrouping = 'all'; // all, seasons, episodes, folders + + // Pagination state + int _currentPage = 0; + bool _hasMoreItems = true; + CancelToken? _cancelToken; + int _requestId = 0; + static const int _pageSize = 500; + + @override + void initState() { + super.initState(); + _loadContent(); + } + + @override + void didUpdateWidget(LibraryBrowseTab oldWidget) { + super.didUpdateWidget(oldWidget); + // Reload if library changed + if (oldWidget.library.key != widget.library.key) { + _loadContent(); + } + } + + @override + void dispose() { + _cancelToken?.cancel(); + super.dispose(); + } + + Future _loadContent() async { + // Cancel any pending request + _cancelToken?.cancel(); + _cancelToken = CancelToken(); + final currentRequestId = ++_requestId; + + // Extract context dependencies before async gap + final clientProvider = context.plexClient; + + setState(() { + _isLoading = true; + _errorMessage = null; + _items = []; + _currentPage = 0; + _hasMoreItems = true; + }); + + try { + final client = clientProvider.client; + if (client == null) { + throw Exception(t.errors.noClientAvailable); + } + + final storage = await StorageService.getInstance(); + + // Load filters and sorts for this library + final filters = await client.getLibraryFilters(widget.library.key); + final sorts = await client.getLibrarySorts(widget.library.key); + + // Load saved preferences + final savedFilters = storage.getLibraryFilters( + sectionId: widget.library.key, + ); + final savedSort = storage.getLibrarySort(widget.library.key); + final savedGrouping = storage.getLibraryGrouping(widget.library.key); + + // Check if request was cancelled + if (currentRequestId != _requestId) return; + + setState(() { + _filters = filters; + _sortOptions = sorts; + _selectedFilters = Map.from(savedFilters); + _selectedGrouping = savedGrouping ?? _getDefaultGrouping(); + + // Restore sort + if (savedSort != null) { + final sortKey = savedSort['key'] as String?; + if (sortKey != null) { + final sort = sorts.where((s) => s.key == sortKey).firstOrNull; + if (sort != null) { + _selectedSort = sort; + _isSortDescending = (savedSort['descending'] as bool?) ?? false; + } + } + } + }); + + // Load items + await _loadItems(); + } catch (e) { + if (currentRequestId != _requestId) return; + + setState(() { + _errorMessage = _getErrorMessage(e); + _isLoading = false; + }); + } + } + + Future _loadItems({bool loadMore = false}) async { + if (loadMore && _isLoading) return; + + if (!loadMore) { + _currentPage = 0; + _hasMoreItems = true; + } + + if (!_hasMoreItems) return; + + final currentRequestId = _requestId; + _cancelToken?.cancel(); + _cancelToken = CancelToken(); + + setState(() { + _isLoading = true; + if (!loadMore) { + _items = []; + } + }); + + try { + final client = context.read().client; + if (client == null) { + throw Exception(t.errors.noClientAvailable); + } + + // Build filter params + final filterParams = Map.from(_selectedFilters); + + // Add grouping type filter (but not for 'all' or 'folders') + if (_selectedGrouping != 'all' && _selectedGrouping != 'folders') { + final typeId = _getGroupingTypeId(); + if (typeId.isNotEmpty) { + filterParams['type'] = typeId; + } + } + + // Add sort + if (_selectedSort != null) { + filterParams['sort'] = _selectedSort!.getSortKey( + descending: _isSortDescending, + ); + } + + final items = await client.getLibraryContent( + widget.library.key, + start: _currentPage * _pageSize, + size: _pageSize, + filters: filterParams, + cancelToken: _cancelToken, + ); + + if (currentRequestId != _requestId) return; + + setState(() { + if (loadMore) { + _items.addAll(items); + } else { + _items = items; + } + _hasMoreItems = items.length >= _pageSize; + _currentPage++; + _isLoading = false; + }); + } catch (e) { + if (currentRequestId != _requestId) return; + + setState(() { + _errorMessage = _getErrorMessage(e); + _isLoading = false; + }); + } + } + + String _getDefaultGrouping() { + final type = widget.library.type.toLowerCase(); + if (type == 'show') { + return 'shows'; + } else if (type == 'movie') { + return 'movies'; + } + return 'all'; + } + + String _getGroupingTypeId() { + switch (_selectedGrouping) { + case 'movies': + return '1'; + case 'shows': + return '2'; + case 'seasons': + return '3'; + case 'episodes': + return '4'; + default: + return ''; + } + } + + List _getGroupingOptions() { + final type = widget.library.type.toLowerCase(); + if (type == 'show') { + return ['shows', 'seasons', 'episodes', 'folders']; + } else if (type == 'movie') { + return ['movies', 'folders']; + } + // All library types support folder browsing + return ['all', 'folders']; + } + + String _getGroupingLabel(String grouping) { + switch (grouping) { + case 'movies': + return t.libraries.groupings.movies; + case 'shows': + return t.libraries.groupings.shows; + case 'seasons': + return t.libraries.groupings.seasons; + case 'episodes': + return t.libraries.groupings.episodes; + case 'folders': + return t.libraries.groupings.folders; + default: + return t.libraries.groupings.all; + } + } + + String _getErrorMessage(dynamic error) { + if (error is DioException) { + switch (error.type) { + case DioExceptionType.connectionTimeout: + case DioExceptionType.receiveTimeout: + return t.errors.connectionTimeout(context: t.libraries.content); + case DioExceptionType.connectionError: + return t.errors.connectionFailed; + default: + appLogger.e('Error loading library content', error: error); + return t.errors.failedToLoad( + context: t.libraries.content, + error: error.message ?? t.common.unknown, + ); + } + } + appLogger.e('Unexpected error loading library content', error: error); + return t.errors.failedToLoad( + context: t.libraries.content, + error: error.toString(), + ); + } + + void _showGroupingBottomSheet() { + showModalBottomSheet( + context: context, + builder: (context) { + return ListView( + shrinkWrap: true, + children: _getGroupingOptions().map((grouping) { + return RadioListTile( + title: Text(_getGroupingLabel(grouping)), + value: grouping, + groupValue: _selectedGrouping, + onChanged: (value) async { + if (value != null) { + setState(() { + _selectedGrouping = value; + }); + + final storage = await StorageService.getInstance(); + await storage.saveLibraryGrouping(widget.library.key, value); + + Navigator.pop(context); + _loadItems(); + } + }, + ); + }).toList(), + ); + }, + ); + } + + void _showFiltersBottomSheet() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => FiltersBottomSheet( + filters: _filters, + selectedFilters: _selectedFilters, + onFiltersChanged: (filters) async { + setState(() { + _selectedFilters.clear(); + _selectedFilters.addAll(filters); + }); + + // Save filters to storage + final storage = await StorageService.getInstance(); + await storage.saveLibraryFilters( + filters, + sectionId: widget.library.key, + ); + + _loadItems(); + }, + ), + ); + } + + void _showSortBottomSheet() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => SortBottomSheet( + sortOptions: _sortOptions, + selectedSort: _selectedSort, + isSortDescending: _isSortDescending, + onSortChanged: (sort, descending) { + setState(() { + _selectedSort = sort; + _isSortDescending = descending; + }); + + StorageService.getInstance().then((storage) { + storage.saveLibrarySort( + widget.library.key, + sort.key, + descending: descending, + ); + }); + + _loadItems(); + }, + ), + ); + } + + Widget _buildFilterChip({ + required IconData icon, + required String label, + required VoidCallback onPressed, + }) { + return InkWell( + onTap: onPressed, + borderRadius: BorderRadius.circular(20), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: 16, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 6), + Text( + label, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + super.build(context); // Required for AutomaticKeepAliveClientMixin + + return Column( + children: [ + // Filter bar with chips + Container( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + alignment: Alignment.centerLeft, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Grouping chip + _buildFilterChip( + icon: Icons.category, + label: _getGroupingLabel(_selectedGrouping), + onPressed: _showGroupingBottomSheet, + ), + const SizedBox(width: 8), + // Filters chip + if (_filters.isNotEmpty) + _buildFilterChip( + icon: Icons.filter_alt, + label: _selectedFilters.isEmpty + ? t.libraries.filters + : t.libraries.filtersWithCount( + count: _selectedFilters.length, + ), + onPressed: _showFiltersBottomSheet, + ), + if (_filters.isNotEmpty) const SizedBox(width: 8), + // Sort chip + if (_sortOptions.isNotEmpty) + _buildFilterChip( + icon: Icons.sort, + label: _selectedSort?.title ?? t.libraries.sort, + onPressed: _showSortBottomSheet, + ), + ], + ), + ), + ), + + // Content + Expanded(child: _buildContent()), + ], + ); + } + + Widget _buildContent() { + // Show folder tree view when in folders mode + if (_selectedGrouping == 'folders') { + return FolderTreeView( + libraryKey: widget.library.key, + onRefresh: updateItem, + ); + } + + if (_isLoading && _items.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + + if (_errorMessage != null && _items.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, size: 48, color: Colors.red), + const SizedBox(height: 16), + Text(_errorMessage!), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadContent, + child: Text(t.common.retry), + ), + ], + ), + ); + } + + if (_items.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.folder_open, size: 64, color: Colors.grey), + const SizedBox(height: 16), + Text(t.libraries.thisLibraryIsEmpty), + ], + ), + ); + } + + return NotificationListener( + onNotification: (notification) { + if (notification.metrics.pixels >= + notification.metrics.maxScrollExtent - 300 && + _hasMoreItems && + !_isLoading) { + _loadItems(loadMore: true); + } + return false; + }, + child: Consumer( + builder: (context, settingsProvider, child) { + if (settingsProvider.viewMode == ViewMode.list) { + return ListView.builder( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), + itemCount: _items.length + (_hasMoreItems && _isLoading ? 1 : 0), + itemBuilder: (context, index) { + if (index >= _items.length) { + return const Padding( + padding: EdgeInsets.all(16.0), + child: Center(child: CircularProgressIndicator()), + ); + } + final item = _items[index]; + return MediaCard( + key: Key(item.ratingKey), + item: item, + onRefresh: updateItem, + ); + }, + ); + } else { + return GridView.builder( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent( + context, + settingsProvider.libraryDensity, + ), + childAspectRatio: 2 / 3.3, + crossAxisSpacing: 0, + mainAxisSpacing: 0, + ), + itemCount: _items.length + (_hasMoreItems && _isLoading ? 1 : 0), + itemBuilder: (context, index) { + if (index >= _items.length) { + return const Center(child: CircularProgressIndicator()); + } + final item = _items[index]; + return MediaCard( + key: Key(item.ratingKey), + item: item, + onRefresh: updateItem, + ); + }, + ); + } + }, + ), + ); + } +} diff --git a/lib/screens/library_tabs/library_collections_tab.dart b/lib/screens/library_tabs/library_collections_tab.dart new file mode 100644 index 00000000..37207f84 --- /dev/null +++ b/lib/screens/library_tabs/library_collections_tab.dart @@ -0,0 +1,195 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../models/plex_library.dart'; +import '../../models/plex_metadata.dart'; +import '../../providers/plex_client_provider.dart'; +import '../../providers/settings_provider.dart'; +import '../../utils/app_logger.dart'; +import '../../utils/library_refresh_notifier.dart'; +import '../../widgets/media_card.dart'; +import '../../services/settings_service.dart' show LibraryDensity, ViewMode; +import '../../utils/grid_size_calculator.dart'; +import '../../i18n/strings.g.dart'; +import '../../mixins/refreshable.dart'; + +/// Collections tab for library screen +/// Shows collections for the current library +class LibraryCollectionsTab extends StatefulWidget { + final PlexLibrary library; + final String? viewMode; + final String? density; + + const LibraryCollectionsTab({ + super.key, + required this.library, + this.viewMode, + this.density, + }); + + @override + State createState() => _LibraryCollectionsTabState(); +} + +class _LibraryCollectionsTabState extends State + with AutomaticKeepAliveClientMixin, Refreshable { + @override + bool get wantKeepAlive => true; + + @override + void refresh() { + _loadCollections(); + } + + List _collections = []; + bool _isLoading = false; + String? _errorMessage; + StreamSubscription? _refreshSubscription; + + @override + void initState() { + super.initState(); + _loadCollections(); + + // Listen for refresh notifications + _refreshSubscription = LibraryRefreshNotifier().collectionsStream.listen((_) { + if (mounted) { + _loadCollections(); + } + }); + } + + @override + void dispose() { + _refreshSubscription?.cancel(); + super.dispose(); + } + + @override + void didUpdateWidget(LibraryCollectionsTab oldWidget) { + super.didUpdateWidget(oldWidget); + // Reload if library changed + if (oldWidget.library.key != widget.library.key) { + _loadCollections(); + } + } + + Future _loadCollections() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final client = context.read().client; + if (client == null) { + throw Exception(t.errors.noClientAvailable); + } + + final collections = await client.getLibraryCollections(widget.library.key); + + if (!mounted) return; + + setState(() { + _collections = collections; + _isLoading = false; + }); + } catch (e) { + if (!mounted) return; + + appLogger.e('Error loading collections', error: e); + setState(() { + _errorMessage = t.errors.failedToLoad( + context: t.collections.title, + error: e.toString(), + ); + _isLoading = false; + }); + } + } + + + @override + Widget build(BuildContext context) { + super.build(context); // Required for AutomaticKeepAliveClientMixin + + if (_isLoading && _collections.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + + if (_errorMessage != null && _collections.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, size: 48, color: Colors.red), + const SizedBox(height: 16), + Text(_errorMessage!), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadCollections, + child: Text(t.common.retry), + ), + ], + ), + ); + } + + if (_collections.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.collections, size: 64, color: Colors.grey), + const SizedBox(height: 16), + Text(t.libraries.noCollections), + ], + ), + ); + } + + return RefreshIndicator( + onRefresh: _loadCollections, + child: Consumer( + builder: (context, settingsProvider, child) { + if (settingsProvider.viewMode == ViewMode.list) { + return ListView.builder( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 8), + itemCount: _collections.length, + itemBuilder: (context, index) { + final collection = _collections[index]; + return MediaCard( + key: Key(collection.ratingKey), + item: collection, + onListRefresh: _loadCollections, + ); + }, + ); + } else { + return GridView.builder( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 8), + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent( + context, + settingsProvider.libraryDensity, + ), + childAspectRatio: 2 / 3.3, + crossAxisSpacing: 0, + mainAxisSpacing: 0, + ), + itemCount: _collections.length, + itemBuilder: (context, index) { + final collection = _collections[index]; + return MediaCard( + key: Key(collection.ratingKey), + item: collection, + onListRefresh: _loadCollections, + ); + }, + ); + } + }, + ), + ); + } +} diff --git a/lib/screens/library_tabs/library_playlists_tab.dart b/lib/screens/library_tabs/library_playlists_tab.dart new file mode 100644 index 00000000..f2d0e2b4 --- /dev/null +++ b/lib/screens/library_tabs/library_playlists_tab.dart @@ -0,0 +1,199 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../models/plex_library.dart'; +import '../../models/plex_playlist.dart'; +import '../../providers/plex_client_provider.dart'; +import '../../providers/settings_provider.dart'; +import '../../utils/app_logger.dart'; +import '../../utils/library_refresh_notifier.dart'; +import '../../services/settings_service.dart' show LibraryDensity, ViewMode; +import '../../utils/grid_size_calculator.dart'; +import '../../widgets/media_card.dart'; +import '../../i18n/strings.g.dart'; +import '../../mixins/refreshable.dart'; + +/// Playlists tab for library screen +/// Shows playlists that contain items from the current library +class LibraryPlaylistsTab extends StatefulWidget { + final PlexLibrary library; + final String? viewMode; + final String? density; + + const LibraryPlaylistsTab({ + super.key, + required this.library, + this.viewMode, + this.density, + }); + + @override + State createState() => _LibraryPlaylistsTabState(); +} + +class _LibraryPlaylistsTabState extends State + with AutomaticKeepAliveClientMixin, Refreshable { + @override + bool get wantKeepAlive => true; + + @override + void refresh() { + _loadPlaylists(); + } + + List _playlists = []; + bool _isLoading = false; + String? _errorMessage; + StreamSubscription? _refreshSubscription; + + @override + void initState() { + super.initState(); + _loadPlaylists(); + + // Listen for refresh notifications + _refreshSubscription = LibraryRefreshNotifier().playlistsStream.listen((_) { + if (mounted) { + _loadPlaylists(); + } + }); + } + + @override + void dispose() { + _refreshSubscription?.cancel(); + super.dispose(); + } + + @override + void didUpdateWidget(LibraryPlaylistsTab oldWidget) { + super.didUpdateWidget(oldWidget); + // Reload if library changed + if (oldWidget.library.key != widget.library.key) { + _loadPlaylists(); + } + } + + Future _loadPlaylists() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final client = context.read().client; + if (client == null) { + throw Exception(t.errors.noClientAvailable); + } + + // Get playlists for this library + final playlists = await client.getLibraryPlaylists( + sectionId: widget.library.key, + playlistType: 'video', + ); + + if (!mounted) return; + + setState(() { + _playlists = playlists; + _isLoading = false; + }); + } catch (e) { + if (!mounted) return; + + appLogger.e('Error loading playlists', error: e); + setState(() { + _errorMessage = t.errors.failedToLoad( + context: t.playlists.title, + error: e.toString(), + ); + _isLoading = false; + }); + } + } + + + @override + Widget build(BuildContext context) { + super.build(context); // Required for AutomaticKeepAliveClientMixin + + if (_isLoading && _playlists.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + + if (_errorMessage != null && _playlists.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, size: 48, color: Colors.red), + const SizedBox(height: 16), + Text(_errorMessage!), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadPlaylists, + child: Text(t.common.retry), + ), + ], + ), + ); + } + + if (_playlists.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.playlist_play, size: 64, color: Colors.grey), + const SizedBox(height: 16), + Text(t.playlists.noPlaylists), + ], + ), + ); + } + + return RefreshIndicator( + onRefresh: _loadPlaylists, + child: Consumer( + builder: (context, settingsProvider, child) { + if (settingsProvider.viewMode == ViewMode.list) { + return ListView.builder( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 8), + itemCount: _playlists.length, + itemBuilder: (context, index) { + final playlist = _playlists[index]; + return MediaCard( + key: Key(playlist.ratingKey), + item: playlist, + onListRefresh: _loadPlaylists, + ); + }, + ); + } else { + return GridView.builder( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 8), + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent( + context, + settingsProvider.libraryDensity, + ), + childAspectRatio: 2 / 3.3, + crossAxisSpacing: 0, + mainAxisSpacing: 0, + ), + itemCount: _playlists.length, + itemBuilder: (context, index) { + final playlist = _playlists[index]; + return MediaCard( + key: Key(playlist.ratingKey), + item: playlist, + onListRefresh: _loadPlaylists, + ); + }, + ); + } + }, + ), + ); + } +} diff --git a/lib/screens/library_tabs/library_recommended_tab.dart b/lib/screens/library_tabs/library_recommended_tab.dart new file mode 100644 index 00000000..53b82423 --- /dev/null +++ b/lib/screens/library_tabs/library_recommended_tab.dart @@ -0,0 +1,162 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../models/plex_library.dart'; +import '../../models/plex_hub.dart'; +import '../../providers/plex_client_provider.dart'; +import '../../utils/app_logger.dart'; +import '../../widgets/hub_section.dart'; +import '../../i18n/strings.g.dart'; +import '../../mixins/refreshable.dart'; + +/// Recommended tab for library screen +/// Shows library-specific hubs and recommendations +class LibraryRecommendedTab extends StatefulWidget { + final PlexLibrary library; + + const LibraryRecommendedTab({ + super.key, + required this.library, + }); + + @override + State createState() => _LibraryRecommendedTabState(); +} + +class _LibraryRecommendedTabState extends State + with AutomaticKeepAliveClientMixin, Refreshable { + @override + bool get wantKeepAlive => true; + + @override + void refresh() { + _loadHubs(); + } + + List _hubs = []; + bool _isLoading = false; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadHubs(); + } + + @override + void didUpdateWidget(LibraryRecommendedTab oldWidget) { + super.didUpdateWidget(oldWidget); + // Reload if library changed + if (oldWidget.library.key != widget.library.key) { + _loadHubs(); + } + } + + Future _loadHubs() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final client = context.read().client; + if (client == null) { + throw Exception(t.errors.noClientAvailable); + } + + final hubs = await client.getLibraryHubs(widget.library.key, limit: 12); + + if (!mounted) return; + + setState(() { + _hubs = hubs; + _isLoading = false; + }); + } catch (e) { + if (!mounted) return; + + appLogger.e('Error loading library hubs', error: e); + setState(() { + _errorMessage = t.errors.failedToLoad( + context: t.libraries.tabs.recommended, + error: e.toString(), + ); + _isLoading = false; + }); + } + } + + IconData _getHubIcon(PlexHub hub) { + final title = hub.title.toLowerCase(); + if (title.contains('continue watching') || title.contains('on deck')) { + return Icons.play_circle; + } else if (title.contains('recently') || title.contains('new')) { + return Icons.fiber_new; + } else if (title.contains('popular') || title.contains('trending')) { + return Icons.trending_up; + } else if (title.contains('top') || title.contains('rated')) { + return Icons.star; + } else if (title.contains('recommended')) { + return Icons.thumb_up; + } else if (title.contains('unwatched')) { + return Icons.visibility_off; + } else if (title.contains('genre')) { + return Icons.category; + } + return Icons.movie; + } + + @override + Widget build(BuildContext context) { + super.build(context); // Required for AutomaticKeepAliveClientMixin + + if (_isLoading && _hubs.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + + if (_errorMessage != null && _hubs.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, size: 48, color: Colors.red), + const SizedBox(height: 16), + Text(_errorMessage!), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadHubs, + child: Text(t.common.retry), + ), + ], + ), + ); + } + + if (_hubs.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.recommend, size: 64, color: Colors.grey), + const SizedBox(height: 16), + Text(t.libraries.noRecommendations), + ], + ), + ); + } + + return RefreshIndicator( + onRefresh: _loadHubs, + child: ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: _hubs.length, + itemBuilder: (context, index) { + final hub = _hubs[index]; + return HubSection( + hub: hub, + icon: _getHubIcon(hub), + ); + }, + ), + ); + } +} diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 0b1afff6..7133550b 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -953,7 +953,7 @@ class _MediaDetailScreenState extends State { return Card( clipBehavior: Clip.antiAlias, child: MediaContextMenu( - metadata: season, + item: season, onRefresh: (ratingKey) { _watchStateChanged = true; _updateWatchState(); diff --git a/lib/screens/playlist_detail_screen.dart b/lib/screens/playlist_detail_screen.dart index bcd25c25..e75851a2 100644 --- a/lib/screens/playlist_detail_screen.dart +++ b/lib/screens/playlist_detail_screen.dart @@ -8,6 +8,7 @@ import '../providers/playback_state_provider.dart'; import '../services/settings_service.dart'; import '../utils/provider_extensions.dart'; import '../utils/app_logger.dart'; +import '../utils/collection_playlist_play_helper.dart'; import '../utils/video_player_navigation.dart'; import '../widgets/media_card.dart'; import '../widgets/playlist_item_card.dart'; @@ -51,7 +52,7 @@ class _PlaylistDetailScreenState extends State final clientProvider = context.plexClient; final client = clientProvider.client; if (client == null) { - throw Exception('No client available'); + throw Exception(t.errors.noClientAvailable); } final items = await client.getPlaylist(widget.playlist.ratingKey); @@ -257,15 +258,16 @@ class _PlaylistDetailScreenState extends State return; } - final playbackState = context.read(); + final clientProvider = context.plexClient; + final client = clientProvider.client; + if (client == null) return; - // Set the playlist items as the playback queue (in order, not shuffled) - playbackState.setPlaybackQueue(_items, widget.playlist.ratingKey); - - // Navigate to the first item - if (mounted) { - await navigateToVideoPlayer(context, metadata: _items.first); - } + await playCollectionOrPlaylist( + context: context, + client: client, + item: widget.playlist, + shuffle: false, + ); } Future _shufflePlayPlaylist() async { @@ -278,31 +280,70 @@ class _PlaylistDetailScreenState extends State return; } - final playbackState = context.read(); + final clientProvider = context.plexClient; + final client = clientProvider.client; + if (client == null) return; - // Shuffle the items - final shuffledItems = List.from(_items)..shuffle(); - - // Set the shuffled playlist items as the playback queue (playlist mode, not shuffle mode) - playbackState.setPlaybackQueue(shuffledItems, widget.playlist.ratingKey); - - // Navigate to the first shuffled item - if (mounted) { - await navigateToVideoPlayer(context, metadata: shuffledItems.first); - } + await playCollectionOrPlaylist( + context: context, + client: client, + item: widget.playlist, + shuffle: true, + ); } Future _playFromItem(int index) async { if (_items.isEmpty || index < 0 || index >= _items.length) return; - final playbackState = context.read(); + try { + final clientProvider = context.plexClient; + final client = clientProvider.client; + if (client == null) return; - // Set the full playlist as playback queue (in order) - playbackState.setPlaybackQueue(_items, widget.playlist.ratingKey); + final selectedItem = _items[index]; - // Start playing from the clicked item - if (mounted) { - await navigateToVideoPlayer(context, metadata: _items[index]); + // Create play queue from playlist, starting at the selected item + final playQueue = await client.createPlayQueue( + playlistID: int.parse(widget.playlist.ratingKey), + type: 'video', + key: selectedItem.key, + ); + + if (playQueue == null || playQueue.items == null || playQueue.items!.isEmpty) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.messages.failedToCreatePlayQueue)), + ); + } + return; + } + + // Set play queue in provider + final playbackState = context.read(); + playbackState.setClient(client); + await playbackState.setPlaybackFromPlayQueue( + playQueue, + widget.playlist.ratingKey, + ); + + // Navigate to selected item (should be first in the queue response) + if (mounted) { + await navigateToVideoPlayer(context, metadata: playQueue.items!.first); + } + } catch (e) { + appLogger.e('Failed to play from item', error: e); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + t.messages.failedPlayback( + action: t.discover.play, + error: e.toString(), + ), + ), + ), + ); + } } } diff --git a/lib/screens/season_detail_screen.dart b/lib/screens/season_detail_screen.dart index 137816d0..dd2ec503 100644 --- a/lib/screens/season_detail_screen.dart +++ b/lib/screens/season_detail_screen.dart @@ -132,7 +132,7 @@ class _SeasonDetailScreenState extends State : 0.0; return MediaContextMenu( - metadata: episode, + item: episode, onRefresh: updateItem, onTap: () async { await navigateToVideoPlayer(context, metadata: episode); diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 53fe04ad..6d04870f 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -92,6 +92,21 @@ class VideoPlayerScreenState extends State appLogger.d('Preferred subtitle track: $subtitleDesc'); } + // Update current item in playback state provider + try { + final playbackState = context.read(); + + // If this item doesn't have a playQueueItemID, it's a standalone item + // Clear any existing queue so next/previous work correctly for this content + if (widget.metadata.playQueueItemID == null) { + playbackState.clearShuffle(); + } else { + playbackState.setCurrentItem(widget.metadata); + } + } catch (e) { + // Provider might not be available yet + } + // Register app lifecycle observer WidgetsBinding.instance.addObserver(this); @@ -301,11 +316,11 @@ class VideoPlayerScreenState extends State if (playbackState.isPlaylistActive) { // For playlists, always use the queue regardless of item type // Playlists can contain both movies and episodes - next = playbackState.getNextEpisode( + next = await playbackState.getNextEpisode( widget.metadata.ratingKey, loopQueue: false, // Don't loop playlists by default ); - previous = playbackState.getPreviousEpisode(widget.metadata.ratingKey); + previous = await playbackState.getPreviousEpisode(widget.metadata.ratingKey); } // Check if shuffle mode is active else if (playbackState.isShuffleActive) { @@ -320,11 +335,11 @@ class VideoPlayerScreenState extends State if (shuffleOrderNavigation) { // Use shuffled order for next/previous - next = playbackState.getNextEpisode( + next = await playbackState.getNextEpisode( widget.metadata.ratingKey, loopQueue: loopQueue, ); - previous = playbackState.getPreviousEpisode( + previous = await playbackState.getPreviousEpisode( widget.metadata.ratingKey, ); } else { diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index 4c16d670..d967425e 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -168,13 +168,25 @@ class StorageService { } // Library Filters (stored as JSON string) - Future saveLibraryFilters(Map filters) async { + Future saveLibraryFilters( + Map filters, { + String? sectionId, + }) async { final jsonString = json.encode(filters); - await _prefs.setString(_keyLibraryFilters, jsonString); + final key = sectionId != null + ? 'library_filters_$sectionId' + : _keyLibraryFilters; + await _prefs.setString(key, jsonString); } - Map getLibraryFilters() { - final jsonString = _prefs.getString(_keyLibraryFilters); + Map getLibraryFilters({String? sectionId}) { + final scopedKey = + sectionId != null ? 'library_filters_$sectionId' : _keyLibraryFilters; + + // Prefer per-library filters when available + final jsonString = _prefs.getString(scopedKey) ?? + // Legacy support: fall back to global filters if present + _prefs.getString(_keyLibraryFilters); if (jsonString == null) return {}; try { @@ -185,14 +197,44 @@ class StorageService { } } - // Library Sort (per-library, stored individually) - Future saveLibrarySort(String sectionId, String sortKey) async { - await _prefs.setString('library_sort_$sectionId', sortKey); + // Library Sort (per-library, stored individually with descending flag) + Future saveLibrarySort( + String sectionId, + String sortKey, { + bool descending = false, + }) async { + final sortData = {'key': sortKey, 'descending': descending}; + await _prefs.setString('library_sort_$sectionId', json.encode(sortData)); } - String getLibrarySort(String sectionId) { - // Return saved sort or default to titleSort (alphabetical) - return _prefs.getString('library_sort_$sectionId') ?? 'titleSort'; + Map? getLibrarySort(String sectionId) { + final jsonString = _prefs.getString('library_sort_$sectionId'); + if (jsonString == null) return null; + + try { + return json.decode(jsonString) as Map; + } catch (e) { + // Legacy support: if it's just a string, return it as the key + return {'key': jsonString, 'descending': false}; + } + } + + // Library Grouping (per-library, e.g., 'movies', 'shows', 'seasons', 'episodes') + Future saveLibraryGrouping(String sectionId, String grouping) async { + await _prefs.setString('library_grouping_$sectionId', grouping); + } + + String? getLibraryGrouping(String sectionId) { + return _prefs.getString('library_grouping_$sectionId'); + } + + // Library Tab (per-library, saves last selected tab index) + Future saveLibraryTab(String sectionId, int tabIndex) async { + await _prefs.setInt('library_tab_$sectionId', tabIndex); + } + + int? getLibraryTab(String sectionId) { + return _prefs.getInt('library_tab_$sectionId'); } // Hidden Libraries (stored as JSON array of library section IDs) diff --git a/lib/utils/collection_playlist_play_helper.dart b/lib/utils/collection_playlist_play_helper.dart new file mode 100644 index 00000000..701afa37 --- /dev/null +++ b/lib/utils/collection_playlist_play_helper.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../client/plex_client.dart'; +import '../models/plex_metadata.dart'; +import '../models/plex_playlist.dart'; +import '../models/play_queue_response.dart'; +import '../providers/playback_state_provider.dart'; +import '../utils/app_logger.dart'; +import '../utils/video_player_navigation.dart'; +import '../i18n/strings.g.dart'; + +/// Helper function to play a collection or playlist +Future playCollectionOrPlaylist({ + required BuildContext context, + required PlexClient client, + required dynamic item, // PlexMetadata (collection) or PlexPlaylist + required bool shuffle, +}) async { + try { + final isCollection = item is PlexMetadata; + final isPlaylist = item is PlexPlaylist; + + if (!isCollection && !isPlaylist) { + throw Exception('Item must be either a collection or playlist'); + } + + String ratingKey = item.ratingKey; + + final PlayQueueResponse? playQueue; + if (isCollection) { + // Validate that machine identifier is available + if (client.config.machineIdentifier == null) { + throw Exception('Machine identifier is required to play collections'); + } + + final collectionUri = + 'server://${client.config.machineIdentifier}/com.plexapp.plugins.library/library/collections/${item.ratingKey}'; + playQueue = await client.createPlayQueue( + uri: collectionUri, + type: 'video', + shuffle: shuffle ? 1 : 0, + ); + } else { + // For playlists, use playlistID parameter + playQueue = await client.createPlayQueue( + playlistID: int.parse(item.ratingKey), + type: 'video', + shuffle: shuffle ? 1 : 0, + ); + } + + // If the queue is empty, try fetching it again with getPlayQueue + if (playQueue != null && + (playQueue.items == null || playQueue.items!.isEmpty)) { + final fetchedQueue = await client.getPlayQueue(playQueue.playQueueID); + + if (fetchedQueue != null && + fetchedQueue.items != null && + fetchedQueue.items!.isNotEmpty) { + if (!context.mounted) return; + + // Set play queue in provider + final playbackState = context.read(); + playbackState.setClient(client); + await playbackState.setPlaybackFromPlayQueue( + fetchedQueue, + ratingKey, + ); + + // Navigate to first item + await navigateToVideoPlayer(context, metadata: fetchedQueue.items!.first); + return; + } + } + + if (playQueue == null || + playQueue.items == null || + playQueue.items!.isEmpty) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(t.messages.failedToCreatePlayQueueNoItems), + ), + ); + } + return; + } + + if (!context.mounted) return; + + // Set play queue in provider + final playbackState = context.read(); + playbackState.setClient(client); + await playbackState.setPlaybackFromPlayQueue( + playQueue, + ratingKey, + ); + + // Navigate to first item + await navigateToVideoPlayer(context, metadata: playQueue.items!.first); + } catch (e) { + appLogger.e('Failed to ${shuffle ? "shuffle play" : "play"}', error: e); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + t.messages.failedPlayback( + action: shuffle ? t.common.shuffle : t.discover.play, + error: e.toString(), + ), + ), + ), + ); + } + } +} diff --git a/lib/utils/grid_size_calculator.dart b/lib/utils/grid_size_calculator.dart new file mode 100644 index 00000000..c397dc49 --- /dev/null +++ b/lib/utils/grid_size_calculator.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import '../services/settings_service.dart' show LibraryDensity; +import '../constants/layout_constants.dart'; + +/// Utility class for calculating consistent grid sizes across the app +class GridSizeCalculator { + /// Screen width breakpoint for tablet devices + static const double tabletBreakpoint = ScreenBreakpoints.tablet; + + /// Screen width breakpoint for desktop devices + static const double desktopBreakpoint = ScreenBreakpoints.desktop; + + /// Calculates the maximum cross-axis extent for grid items based on screen size and density + static double getMaxCrossAxisExtent( + BuildContext context, + LibraryDensity density, + ) { + final screenWidth = MediaQuery.of(context).size.width; + final isDesktop = screenWidth > desktopBreakpoint; + final isTablet = screenWidth > tabletBreakpoint && screenWidth <= desktopBreakpoint; + + switch (density) { + case LibraryDensity.comfortable: + if (isDesktop) return GridLayoutConstants.comfortableDesktop; + if (isTablet) return GridLayoutConstants.comfortableTablet; + return GridLayoutConstants.comfortableMobile; + case LibraryDensity.compact: + if (isDesktop) return GridLayoutConstants.compactDesktop; + if (isTablet) return GridLayoutConstants.compactTablet; + return GridLayoutConstants.compactMobile; + case LibraryDensity.normal: + if (isDesktop) return GridLayoutConstants.normalDesktop; + if (isTablet) return GridLayoutConstants.normalTablet; + return GridLayoutConstants.normalMobile; + } + } + + /// Returns whether the current screen is a desktop-sized screen + static bool isDesktop(BuildContext context) { + return MediaQuery.of(context).size.width > desktopBreakpoint; + } + + /// Returns whether the current screen is a tablet-sized screen + static bool isTablet(BuildContext context) { + final screenWidth = MediaQuery.of(context).size.width; + return screenWidth > tabletBreakpoint && screenWidth <= desktopBreakpoint; + } + + /// Returns whether the current screen is a mobile-sized screen + static bool isMobile(BuildContext context) { + return MediaQuery.of(context).size.width <= tabletBreakpoint; + } +} diff --git a/lib/utils/library_refresh_notifier.dart b/lib/utils/library_refresh_notifier.dart new file mode 100644 index 00000000..fb4d08f4 --- /dev/null +++ b/lib/utils/library_refresh_notifier.dart @@ -0,0 +1,38 @@ +import 'dart:async'; + +/// Notifier for triggering refreshes of library tabs +/// Singleton pattern for global access +class LibraryRefreshNotifier { + static final LibraryRefreshNotifier _instance = LibraryRefreshNotifier._internal(); + + factory LibraryRefreshNotifier() => _instance; + + LibraryRefreshNotifier._internal(); + + // Stream controllers for different tab types + final _collectionsController = StreamController.broadcast(); + final _playlistsController = StreamController.broadcast(); + + // Streams that tabs can listen to + Stream get collectionsStream => _collectionsController.stream; + Stream get playlistsStream => _playlistsController.stream; + + // Methods to trigger refreshes + void notifyCollectionsChanged() { + if (!_collectionsController.isClosed) { + _collectionsController.add(null); + } + } + + void notifyPlaylistsChanged() { + if (!_playlistsController.isClosed) { + _playlistsController.add(null); + } + } + + // Cleanup + void dispose() { + _collectionsController.close(); + _playlistsController.close(); + } +} diff --git a/lib/widgets/empty_state_widget.dart b/lib/widgets/empty_state_widget.dart new file mode 100644 index 00000000..78e173ad --- /dev/null +++ b/lib/widgets/empty_state_widget.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; + +/// A reusable widget for displaying empty states throughout the app +class EmptyStateWidget extends StatelessWidget { + /// The message to display + final String message; + + /// Optional icon to display above the message + final IconData? icon; + + /// Optional callback for action button + final VoidCallback? onAction; + + /// Optional label for the action button + final String? actionLabel; + + const EmptyStateWidget({ + super.key, + required this.message, + this.icon, + this.onAction, + this.actionLabel, + }); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (icon != null) ...[ + Icon( + icon, + size: 64, + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.4), + ), + const SizedBox(height: 16), + ], + Text( + message, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurface + .withOpacity(0.6), + ), + ), + if (onAction != null && actionLabel != null) ...[ + const SizedBox(height: 24), + FilledButton.icon( + onPressed: onAction, + icon: const Icon(Icons.add), + label: Text(actionLabel!), + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/widgets/error_state_widget.dart b/lib/widgets/error_state_widget.dart new file mode 100644 index 00000000..be742f7f --- /dev/null +++ b/lib/widgets/error_state_widget.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; + +/// A reusable widget for displaying error states throughout the app +class ErrorStateWidget extends StatelessWidget { + /// The error message to display + final String message; + + /// Optional icon to display above the message + final IconData? icon; + + /// Optional callback for retry action + final VoidCallback? onRetry; + + /// Optional label for the retry button + final String? retryLabel; + + const ErrorStateWidget({ + super.key, + required this.message, + this.icon, + this.onRetry, + this.retryLabel, + }); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (icon != null) ...[ + Icon( + icon, + size: 64, + color: Theme.of(context).colorScheme.error, + ), + const SizedBox(height: 16), + ], + Text( + message, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Theme.of(context).colorScheme.error, + ), + ), + if (onRetry != null) ...[ + const SizedBox(height: 24), + FilledButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh), + label: Text(retryLabel ?? 'Retry'), + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/widgets/filters_bottom_sheet.dart b/lib/widgets/filters_bottom_sheet.dart new file mode 100644 index 00000000..443179f4 --- /dev/null +++ b/lib/widgets/filters_bottom_sheet.dart @@ -0,0 +1,320 @@ +import 'package:flutter/material.dart'; +import '../models/plex_filter.dart'; +import '../widgets/app_bar_back_button.dart'; +import '../utils/provider_extensions.dart'; +import '../i18n/strings.g.dart'; + +class FiltersBottomSheet extends StatefulWidget { + final List filters; + final Map selectedFilters; + final Function(Map) onFiltersChanged; + + const FiltersBottomSheet({ + super.key, + required this.filters, + required this.selectedFilters, + required this.onFiltersChanged, + }); + + @override + State createState() => _FiltersBottomSheetState(); +} + +class _FiltersBottomSheetState extends State { + PlexFilter? _currentFilter; + List _filterValues = []; + bool _isLoadingValues = false; + final Map _tempSelectedFilters = {}; + final Map _filterDisplayNames = {}; // Cache for display names + late List _sortedFilters; + + @override + void initState() { + super.initState(); + _tempSelectedFilters.addAll(widget.selectedFilters); + _sortFilters(); + } + + void _sortFilters() { + // Separate boolean filters (toggles) from regular filters + final booleanFilters = widget.filters + .where((f) => f.filterType == 'boolean') + .toList(); + final regularFilters = widget.filters + .where((f) => f.filterType != 'boolean') + .toList(); + + // Combine with boolean filters first + _sortedFilters = [...booleanFilters, ...regularFilters]; + } + + bool _isBooleanFilter(PlexFilter filter) { + return filter.filterType == 'boolean'; + } + + Future _loadFilterValues(PlexFilter filter) async { + setState(() { + _currentFilter = filter; + _isLoadingValues = true; + }); + + try { + final client = context.client; + if (client == null) { + throw Exception(t.errors.noClientAvailable); + } + + final values = await client.getFilterValues(filter.key); + setState(() { + _filterValues = values; + _isLoadingValues = false; + }); + } catch (e) { + setState(() { + _filterValues = []; + _isLoadingValues = false; + }); + } + } + + void _goBack() { + setState(() { + _currentFilter = null; + _filterValues = []; + }); + } + + void _applyFilters() { + widget.onFiltersChanged(_tempSelectedFilters); + Navigator.pop(context); + } + + String _extractFilterValue(String key, String filterName) { + if (key.contains('?')) { + final queryStart = key.indexOf('?'); + final queryString = key.substring(queryStart + 1); + final params = Uri.splitQueryString(queryString); + return params[filterName] ?? key; + } else if (key.startsWith('/')) { + return key.split('/').last; + } + return key; + } + + @override + Widget build(BuildContext context) { + return DraggableScrollableSheet( + initialChildSize: 0.7, + minChildSize: 0.5, + maxChildSize: 0.95, + expand: false, + builder: (context, scrollController) { + if (_currentFilter != null) { + // Show filter options view + return Column( + children: [ + // Header with back button + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(color: Theme.of(context).dividerColor), + ), + ), + child: Row( + children: [ + AppBarBackButton( + style: BackButtonStyle.plain, + onPressed: _goBack, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + _currentFilter!.title, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ), + + // Filter options list + if (_isLoadingValues) + const Expanded( + child: Center(child: CircularProgressIndicator()), + ) + else + Expanded( + child: ListView.builder( + controller: scrollController, + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: _filterValues.length + 1, + itemBuilder: (context, index) { + if (index == 0) { + final isSelected = !_tempSelectedFilters.containsKey( + _currentFilter!.filter, + ); + return ListTile( + title: Text(t.libraries.all), + selected: isSelected, + onTap: () { + setState(() { + _tempSelectedFilters.remove( + _currentFilter!.filter, + ); + }); + _applyFilters(); + }, + ); + } + + final value = _filterValues[index - 1]; + final filterValue = _extractFilterValue( + value.key, + _currentFilter!.filter, + ); + final isSelected = + _tempSelectedFilters[_currentFilter!.filter] == + filterValue; + + return ListTile( + title: Text(value.title), + selected: isSelected, + onTap: () { + setState(() { + _tempSelectedFilters[_currentFilter!.filter] = + filterValue; + // Cache the display name for this filter value + _filterDisplayNames['${_currentFilter!.filter}:$filterValue'] = + value.title; + }); + _applyFilters(); + }, + ); + }, + ), + ), + ], + ); + } + + // Show main filters view + return Column( + children: [ + // Header + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(color: Theme.of(context).dividerColor), + ), + ), + child: Row( + children: [ + const Icon(Icons.filter_alt), + const SizedBox(width: 12), + Text( + t.libraries.filters, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + if (_tempSelectedFilters.isNotEmpty) + TextButton.icon( + onPressed: () { + setState(() { + _tempSelectedFilters.clear(); + }); + _applyFilters(); + }, + icon: const Icon(Icons.clear_all), + label: Text(t.libraries.clearAll), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ), + + // All Filters (boolean toggles first, then regular filters) + Expanded( + child: ListView.builder( + controller: scrollController, + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: _sortedFilters.length, + itemBuilder: (context, index) { + final filter = _sortedFilters[index]; + + // Handle boolean filters as switches (unwatched, inProgress, unmatched, hdr, etc.) + if (_isBooleanFilter(filter)) { + final isActive = + _tempSelectedFilters.containsKey(filter.filter) && + _tempSelectedFilters[filter.filter] == '1'; + return SwitchListTile( + value: isActive, + onChanged: (value) { + setState(() { + if (value) { + _tempSelectedFilters[filter.filter] = '1'; + } else { + _tempSelectedFilters.remove(filter.filter); + } + }); + _applyFilters(); + }, + title: Text(filter.title), + ); + } + + // Regular navigable filters - show selected value instead of checkmark + final selectedValue = _tempSelectedFilters[filter.filter]; + String? displayValue; + if (selectedValue != null) { + // Try to get the cached display name, fall back to the value itself + displayValue = + _filterDisplayNames['${filter.filter}:$selectedValue'] ?? + selectedValue; + } + + return ListTile( + title: Text(filter.title), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (displayValue != null) + Flexible( + child: Text( + displayValue, + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w500, + ), + overflow: TextOverflow.ellipsis, + ), + ), + if (displayValue != null) const SizedBox(width: 8), + const Icon(Icons.chevron_right), + ], + ), + onTap: () => _loadFilterValues(filter), + ); + }, + ), + ), + ], + ); + }, + ); + } +} diff --git a/lib/widgets/folder_tree_item.dart b/lib/widgets/folder_tree_item.dart new file mode 100644 index 00000000..384cbdb8 --- /dev/null +++ b/lib/widgets/folder_tree_item.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; +import '../models/plex_metadata.dart'; + +/// Individual item in the folder tree +/// Can be either a folder (expandable) or a file (tappable) +class FolderTreeItem extends StatelessWidget { + final PlexMetadata item; + final int depth; + final bool isExpanded; + final bool isFolder; + final VoidCallback? onTap; + final VoidCallback? onExpand; + final bool isLoading; + + const FolderTreeItem({ + super.key, + required this.item, + required this.depth, + this.isExpanded = false, + this.isFolder = false, + this.onTap, + this.onExpand, + this.isLoading = false, + }); + + IconData _getIcon() { + if (isFolder) { + return Icons.folder; + } + + // File icons based on type + final type = item.type.toLowerCase(); + switch (type) { + case 'movie': + return Icons.movie; + case 'show': + return Icons.tv; + case 'season': + return Icons.video_library; + case 'episode': + return Icons.play_circle_outline; + case 'collection': + return Icons.collections; + default: + return Icons.insert_drive_file; + } + } + + @override + Widget build(BuildContext context) { + final indentation = depth * 24.0; + + return InkWell( + onTap: isFolder ? onExpand : onTap, + child: Container( + padding: EdgeInsets.only( + left: 16.0 + indentation, + right: 16.0, + top: 12.0, + bottom: 12.0, + ), + child: Row( + children: [ + // Expand/collapse icon for folders + if (isFolder) + SizedBox( + width: 24, + child: isLoading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Icon( + isExpanded + ? Icons.keyboard_arrow_down + : Icons.keyboard_arrow_right, + size: 20, + ), + ) + else + const SizedBox(width: 24), + + const SizedBox(width: 8), + + // File/folder icon + Icon( + _getIcon(), + size: 20, + color: isFolder + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.onSurface.withOpacity(0.7), + ), + + const SizedBox(width: 12), + + // Item title + Expanded( + child: Text( + item.title, + style: TextStyle( + fontSize: 14, + fontWeight: isFolder ? FontWeight.w500 : FontWeight.w400, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + + // Additional metadata for files + if (!isFolder && item.year != null) + Text( + item.year.toString(), + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/folder_tree_view.dart b/lib/widgets/folder_tree_view.dart new file mode 100644 index 00000000..b9854406 --- /dev/null +++ b/lib/widgets/folder_tree_view.dart @@ -0,0 +1,268 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../models/plex_metadata.dart'; +import '../providers/plex_client_provider.dart'; +import '../utils/app_logger.dart'; +import '../utils/video_player_navigation.dart'; +import '../screens/media_detail_screen.dart'; +import '../screens/season_detail_screen.dart'; +import 'folder_tree_item.dart'; +import '../i18n/strings.g.dart'; + +/// Expandable tree view for browsing library folders +/// Shows a hierarchical file/folder structure +class FolderTreeView extends StatefulWidget { + final String libraryKey; + final void Function(String)? onRefresh; + + const FolderTreeView({ + super.key, + required this.libraryKey, + this.onRefresh, + }); + + @override + State createState() => _FolderTreeViewState(); +} + +class _FolderTreeViewState extends State { + List _rootFolders = []; + final Map> _childrenCache = {}; + final Set _expandedFolders = {}; + final Set _loadingFolders = {}; + bool _isLoadingRoot = false; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadRootFolders(); + } + + Future _loadRootFolders() async { + setState(() { + _isLoadingRoot = true; + _errorMessage = null; + }); + + try { + final clientProvider = context.read(); + final client = clientProvider.client; + if (client == null) { + throw Exception(t.errors.noClientAvailable); + } + + final folders = await client.getLibraryFolders(widget.libraryKey); + + if (!mounted) return; + + setState(() { + _rootFolders = folders; + _isLoadingRoot = false; + }); + + appLogger.d('Loaded ${folders.length} root folders'); + } catch (e) { + if (!mounted) return; + + appLogger.e('Failed to load root folders', error: e); + setState(() { + _errorMessage = t.errors.failedToLoad( + context: t.libraries.folders, + error: e.toString(), + ); + _isLoadingRoot = false; + }); + } + } + + Future _loadFolderChildren(PlexMetadata folder) async { + // Already loading this folder + if (_loadingFolders.contains(folder.key)) return; + + // Already loaded and cached + if (_childrenCache.containsKey(folder.key)) { + setState(() { + _expandedFolders.add(folder.key); + }); + return; + } + + setState(() { + _loadingFolders.add(folder.key); + }); + + try { + final clientProvider = context.read(); + final client = clientProvider.client; + if (client == null) { + throw Exception(t.errors.noClientAvailable); + } + + final children = await client.getFolderChildren(folder.key); + + if (!mounted) return; + + setState(() { + _childrenCache[folder.key] = children; + _expandedFolders.add(folder.key); + _loadingFolders.remove(folder.key); + }); + + appLogger.d('Loaded ${children.length} children for folder: ${folder.title}'); + } catch (e) { + if (!mounted) return; + + appLogger.e('Failed to load folder children', error: e); + setState(() { + _loadingFolders.remove(folder.key); + }); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + t.errors.failedToLoad( + context: t.libraries.folders, + error: e.toString(), + ), + ), + ), + ); + } + } + } + + void _toggleFolder(PlexMetadata folder) { + if (_expandedFolders.contains(folder.key)) { + setState(() { + _expandedFolders.remove(folder.key); + }); + } else { + _loadFolderChildren(folder); + } + } + + Future _handleItemTap(PlexMetadata item) async { + final itemType = item.type.toLowerCase(); + + // For episodes, start playback directly + if (itemType == 'episode') { + final result = await navigateToVideoPlayer( + context, + metadata: item, + ); + if (result == true) { + widget.onRefresh?.call(item.ratingKey); + } + } else if (itemType == 'season') { + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => SeasonDetailScreen(season: item), + ), + ); + widget.onRefresh?.call(item.ratingKey); + } else { + // For all other types (shows, movies), show detail screen + final result = await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => MediaDetailScreen(metadata: item), + ), + ); + if (result == true) { + widget.onRefresh?.call(item.ratingKey); + } + } + } + + bool _isFolder(PlexMetadata item) { + // Folders typically don't have a specific type or might have special indicators + // Check for common folder indicators + return item.key.contains('/folder') || + item.type.isEmpty || + item.type.toLowerCase() == 'folder'; + } + + List _buildTreeItems(List items, int depth, [String parentPath = '']) { + final List widgets = []; + + for (int i = 0; i < items.length; i++) { + final item = items[i]; + final isFolder = _isFolder(item); + final isExpanded = _expandedFolders.contains(item.key); + final isLoading = _loadingFolders.contains(item.key); + + // Create a unique key path that includes parent hierarchy and index + final itemPath = parentPath.isEmpty ? '$i' : '$parentPath-$i'; + + // Add the item itself + widgets.add( + FolderTreeItem( + key: ValueKey(itemPath), + item: item, + depth: depth, + isFolder: isFolder, + isExpanded: isExpanded, + isLoading: isLoading, + onExpand: isFolder ? () => _toggleFolder(item) : null, + onTap: !isFolder ? () => _handleItemTap(item) : null, + ), + ); + + // Add children if folder is expanded + if (isFolder && isExpanded && _childrenCache.containsKey(item.key)) { + final children = _childrenCache[item.key]!; + widgets.addAll(_buildTreeItems(children, depth + 1, itemPath)); + } + } + + return widgets; + } + + @override + Widget build(BuildContext context) { + if (_isLoadingRoot) { + return const Center(child: CircularProgressIndicator()); + } + + if (_errorMessage != null) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, size: 48, color: Colors.red), + const SizedBox(height: 16), + Text(_errorMessage!), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadRootFolders, + child: Text(t.common.retry), + ), + ], + ), + ); + } + + if (_rootFolders.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.folder_open, size: 64, color: Colors.grey), + const SizedBox(height: 16), + Text(t.libraries.noFoldersFound), + ], + ), + ); + } + + return RefreshIndicator( + onRefresh: _loadRootFolders, + child: ListView( + children: _buildTreeItems(_rootFolders, 0), + ), + ); + } +} diff --git a/lib/widgets/hub_section.dart b/lib/widgets/hub_section.dart new file mode 100644 index 00000000..c2c4f1d1 --- /dev/null +++ b/lib/widgets/hub_section.dart @@ -0,0 +1,136 @@ +import 'package:flutter/material.dart'; +import '../models/plex_hub.dart'; +import '../screens/hub_detail_screen.dart'; +import 'media_card.dart'; +import 'horizontal_scroll_with_arrows.dart'; +import '../i18n/strings.g.dart'; + +/// Shared hub section widget used in both discover and library screens +/// Displays a hub title with icon and a horizontal scrollable list of items +class HubSection extends StatelessWidget { + final PlexHub hub; + final IconData icon; + final void Function(String)? onRefresh; + final VoidCallback? onRemoveFromContinueWatching; + final bool isInContinueWatching; + + const HubSection({ + super.key, + required this.hub, + required this.icon, + this.onRefresh, + this.onRemoveFromContinueWatching, + this.isInContinueWatching = false, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Hub header + Padding( + padding: const EdgeInsets.fromLTRB(16, 24, 16, 8), + child: InkWell( + onTap: hub.more + ? () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => HubDetailScreen(hub: hub), + ), + ); + } + : null, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + child: Row( + children: [ + Icon(icon), + const SizedBox(width: 8), + Text( + hub.title, + style: Theme.of(context).textTheme.titleLarge, + ), + if (hub.more) ...[ + const SizedBox(width: 4), + const Icon(Icons.chevron_right, size: 20), + ], + ], + ), + ), + ), + ), + + // Hub items (horizontal scroll) + if (hub.items.isNotEmpty) + LayoutBuilder( + builder: (context, constraints) { + // Responsive card width based on screen size + final screenWidth = constraints.maxWidth; + final cardWidth = screenWidth > 1600 + ? 220.0 + : screenWidth > 1200 + ? 200.0 + : screenWidth > 800 + ? 190.0 + : 160.0; + + // MediaCard has 8px padding on all sides (16px total horizontally) + // So actual poster width is cardWidth - 16 + final posterWidth = cardWidth - 16; + // 2:3 poster aspect ratio (height is 1.5x width) + final posterHeight = posterWidth * 1.5; + // Container height = poster + padding + spacing + text + // 8px top padding + posterHeight + 4px spacing + ~26px text + 8px bottom padding + final containerHeight = posterHeight + 46; + + return SizedBox( + height: containerHeight, + child: HorizontalScrollWithArrows( + builder: (scrollController) => ListView.builder( + controller: scrollController, + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 12), + itemCount: hub.items.length, + itemBuilder: (context, index) { + final item = hub.items[index]; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: MediaCard( + key: Key(item.ratingKey), + item: item, + width: cardWidth, + height: posterHeight, + onRefresh: onRefresh, + onRemoveFromContinueWatching: + onRemoveFromContinueWatching, + forceGridMode: true, + isInContinueWatching: isInContinueWatching, + ), + ); + }, + ), + ), + ); + }, + ) + else + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text( + t.messages.noItemsAvailable, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.grey, + ), + ), + ), + ], + ); + } +} diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index 0801f86e..4572273a 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:provider/provider.dart'; import '../models/plex_metadata.dart'; +import '../models/plex_playlist.dart'; import '../providers/plex_client_provider.dart'; import '../providers/settings_provider.dart'; import '../services/settings_service.dart'; @@ -10,18 +11,22 @@ import '../utils/video_player_navigation.dart'; import '../utils/content_rating_formatter.dart'; import '../screens/media_detail_screen.dart'; import '../screens/season_detail_screen.dart'; +import '../screens/playlist_detail_screen.dart'; +import '../screens/collection_detail_screen.dart'; import '../theme/theme_helper.dart'; import '../i18n/strings.g.dart'; import 'media_context_menu.dart'; class MediaCard extends StatefulWidget { - final PlexMetadata item; + final dynamic item; // Can be PlexMetadata or PlexPlaylist final double? width; final double? height; final void Function(String ratingKey)? onRefresh; final VoidCallback? onRemoveFromContinueWatching; + final VoidCallback? onListRefresh; // Callback to refresh the entire parent list final bool forceGridMode; final bool isInContinueWatching; + final String? collectionId; // The collection ID if displaying within a collection const MediaCard({ super.key, @@ -30,8 +35,10 @@ class MediaCard extends StatefulWidget { this.height, this.onRefresh, this.onRemoveFromContinueWatching, + this.onListRefresh, this.forceGridMode = false, this.isInContinueWatching = false, + this.collectionId, }); @override @@ -43,8 +50,39 @@ class _MediaCardState extends State { final client = context.client; if (client == null) return; + // Handle playlists + if (widget.item is PlexPlaylist) { + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PlaylistDetailScreen( + playlist: widget.item as PlexPlaylist, + ), + ), + ); + return; + } + final itemType = widget.item.type.toLowerCase(); + // Handle collections + if (itemType == 'collection') { + final result = await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => CollectionDetailScreen( + collection: widget.item, + ), + ), + ); + + // If collection was deleted, refresh the parent list + if (result == true && mounted) { + widget.onListRefresh?.call(); + } + return; + } + // Music content is not yet supported if (itemType == 'artist' || itemType == 'album' || itemType == 'track') { if (context.mounted) { @@ -100,31 +138,36 @@ class _MediaCardState extends State { ? ViewMode.grid : settingsProvider.viewMode; + final cardWidget = viewMode == ViewMode.grid + ? _MediaCardGrid( + item: widget.item, + width: widget.width, + height: widget.height, + onTap: () => _handleTap(context), + ) + : _MediaCardList( + item: widget.item, + onTap: () => _handleTap(context), + density: settingsProvider.libraryDensity, + ); + + // Use context menu for both PlexMetadata and PlexPlaylist items return MediaContextMenu( - metadata: widget.item, + item: widget.item, onRefresh: widget.onRefresh, onRemoveFromContinueWatching: widget.onRemoveFromContinueWatching, + onListRefresh: widget.onListRefresh, onTap: () => _handleTap(context), isInContinueWatching: widget.isInContinueWatching, - child: viewMode == ViewMode.grid - ? _MediaCardGrid( - item: widget.item, - width: widget.width, - height: widget.height, - onTap: () => _handleTap(context), - ) - : _MediaCardList( - item: widget.item, - onTap: () => _handleTap(context), - density: settingsProvider.libraryDensity, - ), + collectionId: widget.collectionId, + child: cardWidget, ); } } /// Grid layout for media cards class _MediaCardGrid extends StatelessWidget { - final PlexMetadata item; + final dynamic item; // Can be PlexMetadata or PlexPlaylist final double? width; final double? height; final VoidCallback onTap; @@ -145,6 +188,7 @@ class _MediaCardGrid extends StatelessWidget { identifier: "media-card-${item.ratingKey}", button: true, child: InkWell( + onTap: onTap, borderRadius: BorderRadius.circular(8), child: Padding( padding: const EdgeInsets.all(8), @@ -167,7 +211,9 @@ class _MediaCardGrid extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.start, children: [ Text( - item.displayTitle, + item is PlexPlaylist + ? (item as PlexPlaylist).title + : (item as PlexMetadata).displayTitle, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( @@ -176,37 +222,85 @@ class _MediaCardGrid extends StatelessWidget { height: 1.1, ), ), - if (item.displaySubtitle != null) - Text( - item.displaySubtitle!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: tokens(context).textMuted, - fontSize: 11, - height: 1.1, - ), + if (item is PlexPlaylist) + Builder( + builder: (context) { + final playlist = item as PlexPlaylist; + if (playlist.leafCount != null && playlist.leafCount! > 0) { + return Text( + t.playlists.itemCount(count: playlist.leafCount!), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: tokens(context).textMuted, + fontSize: 11, + height: 1.1, + ), + ); + } + return const SizedBox.shrink(); + }, ) - else if (item.parentTitle != null) - Text( - item.parentTitle!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: tokens(context).textMuted, - fontSize: 11, - height: 1.1, - ), - ) - else if (item.year != null) - Text( - '${item.year}', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: tokens(context).textMuted, - fontSize: 11, - height: 1.1, - ), + else if (item is PlexMetadata) ...[ + Builder( + builder: (context) { + final metadata = item as PlexMetadata; + + // For collections, show item count + if (metadata.type.toLowerCase() == 'collection') { + final count = metadata.childCount ?? metadata.leafCount; + if (count != null && count > 0) { + return Text( + t.playlists.itemCount(count: count), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: tokens(context).textMuted, + fontSize: 11, + height: 1.1, + ), + ); + } + } + + // For other media types, show subtitle/parent/year + if (metadata.displaySubtitle != null) { + return Text( + metadata.displaySubtitle!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: tokens(context).textMuted, + fontSize: 11, + height: 1.1, + ), + ); + } else if (metadata.parentTitle != null) { + return Text( + metadata.parentTitle!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: tokens(context).textMuted, + fontSize: 11, + height: 1.1, + ), + ); + } else if (metadata.year != null) { + return Text( + '${metadata.year}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: tokens(context).textMuted, + fontSize: 11, + height: 1.1, + ), + ); + } + + return const SizedBox.shrink(); + }, ), + ], ], ), ], @@ -230,22 +324,31 @@ class _MediaCardGrid extends StatelessWidget { } Widget _buildPosterImage(BuildContext context) { - final useSeasonPoster = context.watch().useSeasonPoster; - final posterUrl = item.posterThumb(useSeasonPoster: useSeasonPoster); + String? posterUrl; + IconData fallbackIcon = Icons.movie; + + if (item is PlexPlaylist) { + posterUrl = (item as PlexPlaylist).displayImage; + fallbackIcon = Icons.playlist_play; + } else if (item is PlexMetadata) { + final useSeasonPoster = context.watch().useSeasonPoster; + posterUrl = (item as PlexMetadata).posterThumb(useSeasonPoster: useSeasonPoster); + } + if (posterUrl != null) { return Consumer( builder: (context, clientProvider, child) { final client = clientProvider.client; if (client == null) { - return const SkeletonLoader( + return SkeletonLoader( child: Center( - child: Icon(Icons.movie, size: 40, color: Colors.white54), + child: Icon(fallbackIcon, size: 40, color: Colors.white54), ), ); } return CachedNetworkImage( - imageUrl: client.getThumbnailUrl(posterUrl), + imageUrl: client.getThumbnailUrl(posterUrl!), fit: BoxFit.cover, width: double.infinity, height: double.infinity, @@ -254,15 +357,15 @@ class _MediaCardGrid extends StatelessWidget { placeholder: (context, url) => const SkeletonLoader(), errorWidget: (context, url, error) => Container( color: Theme.of(context).colorScheme.surfaceContainerHighest, - child: const Center(child: Icon(Icons.broken_image, size: 40)), + child: Center(child: Icon(fallbackIcon, size: 40)), ), ); }, ); } else { - return const SkeletonLoader( + return SkeletonLoader( child: Center( - child: Icon(Icons.movie, size: 40, color: Colors.white54), + child: Icon(fallbackIcon, size: 40, color: Colors.white54), ), ); } @@ -271,7 +374,7 @@ class _MediaCardGrid extends StatelessWidget { /// List layout for media cards class _MediaCardList extends StatelessWidget { - final PlexMetadata item; + final dynamic item; // Can be PlexMetadata or PlexPlaylist final VoidCallback onTap; final LibraryDensity density; @@ -366,48 +469,84 @@ class _MediaCardList extends StatelessWidget { String _buildMetadataLine() { final parts = []; - // Add content rating - if (item.contentRating != null && item.contentRating!.isNotEmpty) { - final rating = formatContentRating(item.contentRating); - if (rating.isNotEmpty) { - parts.add(rating); + if (item is PlexPlaylist) { + final playlist = item as PlexPlaylist; + // Add item count + if (playlist.leafCount != null && playlist.leafCount! > 0) { + parts.add(t.playlists.itemCount(count: playlist.leafCount!)); } - } - // Add year - if (item.year != null) { - parts.add('${item.year}'); - } + // Add duration + if (playlist.formattedDuration != null) { + parts.add(playlist.formattedDuration!); + } - // Add duration - if (item.duration != null) { - parts.add(_formatDuration(item.duration!)); - } + // Add smart playlist badge + if (playlist.smart) { + parts.add(t.playlists.smartPlaylist); + } + } else if (item is PlexMetadata) { + final metadata = item as PlexMetadata; - // Add user rating - if (item.rating != null) { - parts.add('${item.rating!.toStringAsFixed(1)}★'); - } + // For collections, show item count + if (metadata.type.toLowerCase() == 'collection') { + final count = metadata.childCount ?? metadata.leafCount; + if (count != null && count > 0) { + parts.add(t.playlists.itemCount(count: count)); + } + } else { + // For other media types, show standard metadata + // Add content rating + if (metadata.contentRating != null && metadata.contentRating!.isNotEmpty) { + final rating = formatContentRating(metadata.contentRating); + if (rating.isNotEmpty) { + parts.add(rating); + } + } - // Add studio - if (item.studio != null && item.studio!.isNotEmpty) { - parts.add(item.studio!); + // Add year + if (metadata.year != null) { + parts.add('${metadata.year}'); + } + + // Add duration + if (metadata.duration != null) { + parts.add(_formatDuration(metadata.duration!)); + } + + // Add user rating + if (metadata.rating != null) { + parts.add('${metadata.rating!.toStringAsFixed(1)}★'); + } + + // Add studio + if (metadata.studio != null && metadata.studio!.isNotEmpty) { + parts.add(metadata.studio!); + } + } } return parts.join(' • '); } String? _buildSubtitleText() { - // For TV episodes, show S#E# format - if (item.parentIndex != null && item.index != null) { - return 'S${item.parentIndex} E${item.index}'; - } + if (item is PlexPlaylist) { + // Playlists don't have subtitles + return null; + } else if (item is PlexMetadata) { + final metadata = item as PlexMetadata; - // Otherwise use existing subtitle logic - if (item.displaySubtitle != null) { - return item.displaySubtitle; - } else if (item.parentTitle != null) { - return item.parentTitle; + // For TV episodes, show S#E# format + if (metadata.parentIndex != null && metadata.index != null) { + return 'S${metadata.parentIndex} E${metadata.index}'; + } + + // Otherwise use existing subtitle logic + if (metadata.displaySubtitle != null) { + return metadata.displaySubtitle; + } else if (metadata.parentTitle != null) { + return metadata.parentTitle; + } } // Year is now shown in metadata line, so don't show it here @@ -424,6 +563,7 @@ class _MediaCardList extends StatelessWidget { identifier: "media-card-${item.ratingKey}", button: true, child: InkWell( + onTap: onTap, borderRadius: BorderRadius.circular(8), child: Padding( padding: const EdgeInsets.all(8), @@ -520,22 +660,31 @@ class _MediaCardList extends StatelessWidget { } Widget _buildPosterImage(BuildContext context) { - final useSeasonPoster = context.watch().useSeasonPoster; - final posterUrl = item.posterThumb(useSeasonPoster: useSeasonPoster); + String? posterUrl; + IconData fallbackIcon = Icons.movie; + + if (item is PlexPlaylist) { + posterUrl = (item as PlexPlaylist).displayImage; + fallbackIcon = Icons.playlist_play; + } else if (item is PlexMetadata) { + final useSeasonPoster = context.watch().useSeasonPoster; + posterUrl = (item as PlexMetadata).posterThumb(useSeasonPoster: useSeasonPoster); + } + if (posterUrl != null) { return Consumer( builder: (context, clientProvider, child) { final client = clientProvider.client; if (client == null) { - return const SkeletonLoader( + return SkeletonLoader( child: Center( - child: Icon(Icons.movie, size: 40, color: Colors.white54), + child: Icon(fallbackIcon, size: 40, color: Colors.white54), ), ); } return CachedNetworkImage( - imageUrl: client.getThumbnailUrl(posterUrl), + imageUrl: client.getThumbnailUrl(posterUrl!), fit: BoxFit.cover, width: double.infinity, height: double.infinity, @@ -544,15 +693,15 @@ class _MediaCardList extends StatelessWidget { placeholder: (context, url) => const SkeletonLoader(), errorWidget: (context, url, error) => Container( color: Theme.of(context).colorScheme.surfaceContainerHighest, - child: const Center(child: Icon(Icons.broken_image, size: 40)), + child: Center(child: Icon(fallbackIcon, size: 40)), ), ); }, ); } else { - return const SkeletonLoader( + return SkeletonLoader( child: Center( - child: Icon(Icons.movie, size: 40, color: Colors.white54), + child: Icon(fallbackIcon, size: 40, color: Colors.white54), ), ); } @@ -561,16 +710,23 @@ class _MediaCardList extends StatelessWidget { /// Overlay widget for poster showing watched indicator and progress bar class _PosterOverlay extends StatelessWidget { - final PlexMetadata item; + final dynamic item; // Can be PlexMetadata or PlexPlaylist const _PosterOverlay({required this.item}); @override Widget build(BuildContext context) { + // Only show overlays for PlexMetadata items + if (item is! PlexMetadata) { + return const SizedBox.shrink(); + } + + final metadata = item as PlexMetadata; + return Stack( children: [ // Watched indicator (checkmark) - if (item.isWatched) + if (metadata.isWatched) Positioned( top: 4, right: 4, @@ -590,10 +746,10 @@ class _PosterOverlay extends StatelessWidget { ), ), // Progress bar for partially watched content - if (item.viewOffset != null && - item.duration != null && - item.viewOffset! > 0 && - !item.isWatched) + if (metadata.viewOffset != null && + metadata.duration != null && + metadata.viewOffset! > 0 && + !metadata.isWatched) Positioned( bottom: 0, left: 0, @@ -604,7 +760,7 @@ class _PosterOverlay extends StatelessWidget { bottomRight: Radius.circular(8), ), child: LinearProgressIndicator( - value: item.viewOffset! / item.duration!, + value: metadata.viewOffset! / metadata.duration!, backgroundColor: tokens(context).outline, valueColor: AlwaysStoppedAnimation( Theme.of(context).colorScheme.primary, diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index 66c72186..67f0327f 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -4,6 +4,8 @@ import '../models/plex_metadata.dart'; import '../models/plex_playlist.dart'; import '../utils/provider_extensions.dart'; import '../utils/app_logger.dart'; +import '../utils/collection_playlist_play_helper.dart'; +import '../utils/library_refresh_notifier.dart'; import '../screens/media_detail_screen.dart'; import '../screens/season_detail_screen.dart'; import '../widgets/file_info_bottom_sheet.dart'; @@ -22,21 +24,25 @@ class _MenuAction { /// A reusable wrapper widget that adds a context menu (long press / right click) /// to any media item with appropriate actions based on the item type. class MediaContextMenu extends StatefulWidget { - final PlexMetadata metadata; + final dynamic item; // Can be PlexMetadata or PlexPlaylist final void Function(String ratingKey)? onRefresh; final VoidCallback? onRemoveFromContinueWatching; + final VoidCallback? onListRefresh; // For refreshing list after deletion final VoidCallback? onTap; final Widget child; final bool isInContinueWatching; + final String? collectionId; // The collection ID if displaying within a collection const MediaContextMenu({ super.key, - required this.metadata, + required this.item, this.onRefresh, this.onRemoveFromContinueWatching, + this.onListRefresh, this.onTap, required this.child, this.isInContinueWatching = false, + this.collectionId, }); @override @@ -54,12 +60,16 @@ class _MediaContextMenuState extends State { final client = context.client; if (client == null) return; - final itemType = widget.metadata.type.toLowerCase(); - final isPartiallyWatched = - widget.metadata.viewedLeafCount != null && - widget.metadata.leafCount != null && - widget.metadata.viewedLeafCount! > 0 && - widget.metadata.viewedLeafCount! < widget.metadata.leafCount!; + final isPlaylist = widget.item is PlexPlaylist; + final metadata = isPlaylist ? null : widget.item as PlexMetadata; + final itemType = isPlaylist ? 'playlist' : (metadata!.type.toLowerCase()); + final isCollection = itemType == 'collection'; + + final isPartiallyWatched = !isPlaylist && + metadata!.viewedLeafCount != null && + metadata.leafCount != null && + metadata.viewedLeafCount! > 0 && + metadata.viewedLeafCount! < widget.item.leafCount!; // Check if we should use bottom sheet (on iOS and Android) final useBottomSheet = Platform.isIOS || Platform.isAndroid; @@ -67,8 +77,41 @@ class _MediaContextMenuState extends State { // Build menu actions final menuActions = <_MenuAction>[]; + // Special actions for collections and playlists + if (isCollection || isPlaylist) { + // Play + menuActions.add( + _MenuAction( + value: 'play', + icon: Icons.play_arrow, + label: t.discover.play, + ), + ); + + // Shuffle + menuActions.add( + _MenuAction( + value: 'shuffle', + icon: Icons.shuffle, + label: t.mediaMenu.shufflePlay, + ), + ); + + // Delete + menuActions.add( + _MenuAction( + value: 'delete', + icon: Icons.delete, + label: t.common.delete, + ), + ); + + // Skip other menu items for collections and playlists + } else { + // Regular menu items for other types + // Mark as Watched - if (!widget.metadata.isWatched || isPartiallyWatched) { + if (!metadata!.isWatched || isPartiallyWatched) { menuActions.add( _MenuAction( value: 'watch', @@ -79,7 +122,7 @@ class _MediaContextMenuState extends State { } // Mark as Unwatched - if (widget.metadata.isWatched || isPartiallyWatched) { + if (metadata.isWatched || isPartiallyWatched) { menuActions.add( _MenuAction( value: 'unwatch', @@ -100,9 +143,20 @@ class _MediaContextMenuState extends State { ); } + // Remove from Collection (only when viewing items within a collection) + if (widget.collectionId != null) { + menuActions.add( + _MenuAction( + value: 'remove_from_collection', + icon: Icons.delete_outline, + label: t.collections.removeFromCollection, + ), + ); + } + // Go to Series (for episodes and seasons) if ((itemType == 'episode' || itemType == 'season') && - widget.metadata.grandparentTitle != null) { + metadata.grandparentTitle != null) { menuActions.add( _MenuAction( value: 'series', @@ -113,7 +167,7 @@ class _MediaContextMenuState extends State { } // Go to Season (for episodes) - if (itemType == 'episode' && widget.metadata.parentTitle != null) { + if (itemType == 'episode' && metadata.parentTitle != null) { menuActions.add( _MenuAction( value: 'season', @@ -145,19 +199,20 @@ class _MediaContextMenuState extends State { ); } - // Add to Playlist (for episodes, movies, shows, and seasons) + // Add to... (for episodes, movies, shows, and seasons) if (itemType == 'episode' || itemType == 'movie' || itemType == 'show' || itemType == 'season') { menuActions.add( _MenuAction( - value: 'add_to_playlist', - icon: Icons.playlist_add, - label: t.playlists.addTo, + value: 'add_to', + icon: Icons.add, + label: t.common.addTo, ), ); } + } // End of regular menu items else block String? selected; @@ -172,7 +227,7 @@ class _MediaContextMenuState extends State { Padding( padding: const EdgeInsets.all(16.0), child: Text( - widget.metadata.title, + widget.item.title, style: Theme.of(context).textTheme.titleMedium, maxLines: 1, overflow: TextOverflow.ellipsis, @@ -249,7 +304,7 @@ class _MediaContextMenuState extends State { case 'watch': await _executeAction( context, - () => client.markAsWatched(widget.metadata.ratingKey), + () => client.markAsWatched(metadata!.ratingKey), t.messages.markedAsWatched, ); break; @@ -257,7 +312,7 @@ class _MediaContextMenuState extends State { case 'unwatch': await _executeAction( context, - () => client.markAsUnwatched(widget.metadata.ratingKey), + () => client.markAsUnwatched(metadata!.ratingKey), t.messages.markedAsUnwatched, ); break; @@ -267,7 +322,7 @@ class _MediaContextMenuState extends State { // This preserves the progression for partially watched items // and doesn't mark unwatched next episodes as watched try { - await client.removeFromOnDeck(widget.metadata.ratingKey); + await client.removeFromOnDeck(metadata!.ratingKey); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(t.messages.removedFromContinueWatching)), @@ -276,7 +331,7 @@ class _MediaContextMenuState extends State { if (widget.onRemoveFromContinueWatching != null) { widget.onRemoveFromContinueWatching!(); } else { - widget.onRefresh?.call(widget.metadata.ratingKey); + widget.onRefresh?.call(metadata.ratingKey); } } } catch (e) { @@ -290,10 +345,14 @@ class _MediaContextMenuState extends State { } break; + case 'remove_from_collection': + await _handleRemoveFromCollection(context, metadata!); + break; + case 'series': await _navigateToRelated( context, - widget.metadata.grandparentRatingKey, + metadata!.grandparentRatingKey, (metadata) => MediaDetailScreen(metadata: metadata), t.messages.errorLoadingSeries, ); @@ -302,7 +361,7 @@ class _MediaContextMenuState extends State { case 'season': await _navigateToRelated( context, - widget.metadata.parentRatingKey, + metadata!.parentRatingKey, (metadata) => SeasonDetailScreen(season: metadata), t.messages.errorLoadingSeason, ); @@ -312,12 +371,24 @@ class _MediaContextMenuState extends State { await _showFileInfo(context); break; - case 'add_to_playlist': - await _showAddToPlaylistDialog(context); + case 'add_to': + await _showAddToSubmenu(context); break; case 'shuffle_play': - await handleShufflePlay(context, widget.metadata); + await handleShufflePlay(context, metadata!); + break; + + case 'play': + await _handlePlay(context, isCollection, isPlaylist); + break; + + case 'shuffle': + await _handleShuffle(context, isCollection, isPlaylist); + break; + + case 'delete': + await _handleDelete(context, isCollection, isPlaylist); break; } } @@ -334,7 +405,7 @@ class _MediaContextMenuState extends State { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(successMessage))); - widget.onRefresh?.call(widget.metadata.ratingKey); + widget.onRefresh?.call(widget.item.ratingKey); } } catch (e) { if (context.mounted) { @@ -365,7 +436,7 @@ class _MediaContextMenuState extends State { context, MaterialPageRoute(builder: (context) => screenBuilder(metadata)), ); - widget.onRefresh?.call(widget.metadata.ratingKey); + widget.onRefresh?.call(widget.item.ratingKey); } } catch (e) { if (context.mounted) { @@ -393,7 +464,8 @@ class _MediaContextMenuState extends State { } // Fetch file info - final fileInfo = await client.getFileInfo(widget.metadata.ratingKey); + final metadata = widget.item as PlexMetadata; + final fileInfo = await client.getFileInfo(metadata.ratingKey); // Close loading indicator if (context.mounted) { @@ -408,7 +480,7 @@ class _MediaContextMenuState extends State { backgroundColor: Colors.transparent, builder: (context) => FileInfoBottomSheet( fileInfo: fileInfo, - title: widget.metadata.title, + title: metadata.title, ), ); } else if (context.mounted) { @@ -432,13 +504,95 @@ class _MediaContextMenuState extends State { } } + /// Show submenu for Add to... (Playlist or Collection) + Future _showAddToSubmenu(BuildContext context) async { + final useBottomSheet = Platform.isIOS || Platform.isAndroid; + + final submenuActions = [ + _MenuAction( + value: 'playlist', + icon: Icons.playlist_play, + label: t.playlists.playlist, + ), + _MenuAction( + value: 'collection', + icon: Icons.collections, + label: t.collections.collection, + ), + ]; + + String? selected; + + if (useBottomSheet) { + // Show bottom sheet on mobile + selected = await showModalBottomSheet( + context: context, + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Text( + t.common.addTo, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ...submenuActions.map((action) { + return ListTile( + leading: Icon(action.icon), + title: Text(action.label), + onTap: () => Navigator.pop(context, action.value), + ); + }), + const SizedBox(height: 8), + ], + ), + ), + ); + } else { + // Show popup menu on desktop + selected = await showMenu( + context: context, + position: RelativeRect.fromLTRB( + _tapPosition?.dx ?? 0, + _tapPosition?.dy ?? 0, + _tapPosition?.dx ?? 0, + _tapPosition?.dy ?? 0, + ), + items: submenuActions.map((action) { + return PopupMenuItem( + value: action.value, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(action.icon, size: 20), + const SizedBox(width: 12), + Text(action.label), + ], + ), + ); + }).toList(), + ); + } + + // Handle the submenu selection + if (selected == 'playlist' && context.mounted) { + await _showAddToPlaylistDialog(context); + } else if (selected == 'collection' && context.mounted) { + await _showAddToCollectionDialog(context); + } + } + /// Show dialog to select playlist and add item Future _showAddToPlaylistDialog(BuildContext context) async { final client = context.client; if (client == null) return; try { - final itemType = widget.metadata.type.toLowerCase(); + final metadata = widget.item as PlexMetadata; + final itemType = metadata.type.toLowerCase(); // Load playlists final playlists = await client.getPlaylists(playlistType: 'video'); @@ -455,7 +609,7 @@ class _MediaContextMenuState extends State { // Build URI for the item (works for all types: movies, episodes, seasons, shows) // For seasons/shows, the Plex API should automatically expand to include all episodes - final itemUri = await client.buildMetadataUri(widget.metadata.ratingKey); + final itemUri = await client.buildMetadataUri(metadata.ratingKey); appLogger.d('Built URI for $itemType: $itemUri'); if (result == '_create_new') { @@ -484,6 +638,8 @@ class _MediaContextMenuState extends State { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(t.playlists.created))); + // Trigger refresh of playlists tab + LibraryRefreshNotifier().notifyPlaylistsChanged(); } else { appLogger.e('Failed to create playlist - API returned null'); ScaffoldMessenger.of( @@ -505,6 +661,8 @@ class _MediaContextMenuState extends State { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(t.playlists.itemAdded))); + // Trigger refresh of playlists tab + LibraryRefreshNotifier().notifyPlaylistsChanged(); } else { appLogger.e( 'Failed to add item(s) to playlist $result - API returned false', @@ -532,6 +690,388 @@ class _MediaContextMenuState extends State { } } + /// Show dialog to select collection and add item + Future _showAddToCollectionDialog(BuildContext context) async { + final client = context.client; + if (client == null) return; + + try { + final metadata = widget.item as PlexMetadata; + final itemType = metadata.type.toLowerCase(); + + // Get the library section ID from the item + // First try from the metadata itself + int? sectionId = metadata.librarySectionID; + appLogger.d('Attempting to get section ID for ${metadata.title}'); + appLogger.d(' - librarySectionID: $sectionId'); + appLogger.d(' - key: ${metadata.key}'); + + // If not available, fetch the full metadata which should include the section ID + if (sectionId == null) { + try { + appLogger.d(' - Fetching full metadata for: ${metadata.ratingKey}'); + final fullMetadata = await client.getMetadata(metadata.ratingKey); + if (fullMetadata != null) { + sectionId = fullMetadata.librarySectionID; + appLogger.d(' - Section ID from full metadata: $sectionId'); + } + } catch (e) { + appLogger.w('Failed to get full metadata for section ID: $e'); + } + } + + // If still not found, try to extract from the key field + if (sectionId == null) { + final keyMatch = RegExp(r'/library/sections/(\d+)').firstMatch(metadata.key); + if (keyMatch != null) { + sectionId = int.tryParse(keyMatch.group(1)!); + appLogger.d(' - Extracted from key: $sectionId'); + } + } + + // Last resort: try to get it from the item's parent (for episodes/seasons) + if (sectionId == null && metadata.grandparentRatingKey != null) { + try { + appLogger.d(' - Trying to get from parent: ${metadata.grandparentRatingKey}'); + final parentMeta = await client.getMetadata(metadata.grandparentRatingKey!); + sectionId = parentMeta?.librarySectionID; + appLogger.d(' - Parent sectionId: $sectionId'); + } catch (e) { + appLogger.w('Failed to get parent metadata for section ID: $e'); + } + } + + appLogger.d(' - Final sectionId: $sectionId'); + + if (sectionId == null) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Unable to determine library section for this item'), + ), + ); + } + return; + } + + // Load collections for this library section + final collections = await client.getLibraryCollections(sectionId.toString()); + + if (!context.mounted) return; + + // Show dialog to select collection or create new + final result = await showDialog( + context: context, + builder: (context) => _CollectionSelectionDialog(collections: collections), + ); + + if (result == null || !context.mounted) return; + + // Build URI for the item + final itemUri = await client.buildMetadataUri(metadata.ratingKey); + appLogger.d('Built URI for $itemType: $itemUri'); + + if (result == '_create_new') { + // Create new collection flow + final collectionName = await showDialog( + context: context, + builder: (context) => _CreateCollectionDialog(), + ); + + if (collectionName == null || collectionName.isEmpty || !context.mounted) { + return; + } + + // Create collection first (without items) + // Determine the collection type based on the item type + int? collectionType; + switch (itemType) { + case 'movie': + collectionType = 1; + break; + case 'show': + collectionType = 2; + break; + case 'season': + collectionType = 3; + break; + case 'episode': + collectionType = 4; + break; + } + + appLogger.d('Creating collection "$collectionName" with type $collectionType'); + final newCollectionId = await client.createCollection( + sectionId: sectionId.toString(), + title: collectionName, + uri: '', // Empty for regular collections + type: collectionType, + ); + + if (context.mounted) { + if (newCollectionId != null) { + appLogger.d('Successfully created collection with ID: $newCollectionId'); + + // Now add the item to the newly created collection + appLogger.d('Adding item to new collection $newCollectionId with URI: $itemUri'); + final addSuccess = await client.addToCollection( + collectionId: newCollectionId, + uri: itemUri, + ); + + if (addSuccess) { + appLogger.d('Successfully added item to new collection'); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.collections.created))); + // Trigger refresh of collections tab + LibraryRefreshNotifier().notifyCollectionsChanged(); + } else { + appLogger.e('Failed to add item to new collection'); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.collections.errorAddingToCollection))); + } + } else { + appLogger.e('Failed to create collection - API returned null'); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.collections.errorAddingToCollection))); + } + } + } else { + // Add to existing collection + appLogger.d('Adding to collection $result with URI: $itemUri'); + final success = await client.addToCollection( + collectionId: result, + uri: itemUri, + ); + + if (context.mounted) { + if (success) { + appLogger.d('Successfully added item(s) to collection $result'); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.collections.addedToCollection))); + // Trigger refresh of collections tab + LibraryRefreshNotifier().notifyCollectionsChanged(); + } else { + appLogger.e( + 'Failed to add item(s) to collection $result - API returned false', + ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.collections.errorAddingToCollection))); + } + } + } + } catch (e, stackTrace) { + appLogger.e( + 'Error in add to collection flow', + error: e, + stackTrace: stackTrace, + ); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('${t.collections.errorAddingToCollection}: ${e.toString()}'), + duration: const Duration(seconds: 5), + ), + ); + } + } + } + + /// Handle remove from collection action + Future _handleRemoveFromCollection(BuildContext context, PlexMetadata metadata) async { + final client = context.client; + if (client == null) return; + + if (widget.collectionId == null) { + appLogger.e('Cannot remove from collection: collectionId is null'); + return; + } + + // Show confirmation dialog + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(t.collections.removeFromCollection), + content: Text( + t.collections.removeFromCollectionConfirm(title: metadata.title), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(t.common.cancel), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: Text(t.common.delete), + ), + ], + ), + ); + + if (confirmed != true || !context.mounted) return; + + try { + appLogger.d( + 'Removing item ${metadata.ratingKey} from collection ${widget.collectionId}', + ); + final success = await client.removeFromCollection( + collectionId: widget.collectionId!, + itemId: metadata.ratingKey, + ); + + if (context.mounted) { + if (success) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(t.collections.removedFromCollection), + ), + ); + // Trigger refresh of collections tab + LibraryRefreshNotifier().notifyCollectionsChanged(); + // Trigger list refresh to remove the item from the view + widget.onListRefresh?.call(); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(t.collections.removeFromCollectionFailed), + ), + ); + } + } + } catch (e) { + appLogger.e('Failed to remove from collection', error: e); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + t.collections.removeFromCollectionError(error: e.toString()), + ), + ), + ); + } + } + } + + /// Handle play action for collections and playlists + Future _handlePlay(BuildContext context, bool isCollection, bool isPlaylist) async { + final client = context.client; + if (client == null) return; + + await playCollectionOrPlaylist( + context: context, + client: client, + item: widget.item, + shuffle: false, + ); + } + + /// Handle shuffle action for collections and playlists + Future _handleShuffle(BuildContext context, bool isCollection, bool isPlaylist) async { + final client = context.client; + if (client == null) return; + + await playCollectionOrPlaylist( + context: context, + client: client, + item: widget.item, + shuffle: true, + ); + } + + /// Handle delete action for collections and playlists + Future _handleDelete(BuildContext context, bool isCollection, bool isPlaylist) async { + final client = context.client; + if (client == null) return; + + final itemTitle = widget.item.title; + final itemTypeLabel = + isCollection ? t.collections.collection : t.playlists.playlist; + + // Show confirmation dialog + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text( + isCollection ? t.collections.deleteCollection : t.playlists.delete, + ), + content: Text( + isCollection + ? t.collections.deleteConfirm(title: itemTitle) + : t.playlists.deleteMessage(name: itemTitle), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(t.common.cancel), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: Text(t.common.delete), + ), + ], + ), + ); + + if (confirmed != true || !context.mounted) return; + + try { + bool success = false; + + if (isCollection) { + final metadata = widget.item as PlexMetadata; + final sectionId = metadata.librarySectionID?.toString() ?? '0'; + success = await client.deleteCollection(sectionId, metadata.ratingKey); + } else if (isPlaylist) { + final playlist = widget.item as PlexPlaylist; + success = await client.deletePlaylist(playlist.ratingKey); + } + + if (context.mounted) { + if (success) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + isCollection ? t.collections.deleted : t.playlists.deleted, + ), + ), + ); + // Trigger list refresh + widget.onListRefresh?.call(); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + isCollection + ? t.collections.deleteFailed + : t.playlists.errorDeleting, + ), + ), + ); + } + } + } catch (e) { + appLogger.e('Failed to delete $itemTypeLabel', error: e); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + isCollection + ? t.collections.deleteFailedWithError(error: e.toString()) + : t.playlists.errorDeleting, + ), + ), + ); + } + } + } + @override Widget build(BuildContext context) { return GestureDetector( @@ -650,3 +1190,100 @@ class _CreatePlaylistDialogState extends State<_CreatePlaylistDialog> { ); } } + +/// Dialog to select a collection or create a new one +class _CollectionSelectionDialog extends StatelessWidget { + final List collections; + + const _CollectionSelectionDialog({required this.collections}); + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(t.collections.selectCollection), + content: SizedBox( + width: double.maxFinite, + child: ListView.builder( + shrinkWrap: true, + itemCount: collections.length + 1, + itemBuilder: (context, index) { + if (index == 0) { + // Create new collection option (always shown first) + return ListTile( + leading: const Icon(Icons.add), + title: Text(t.collections.createNewCollection), + onTap: () => Navigator.pop(context, '_create_new'), + ); + } + + final collection = collections[index - 1]; + return ListTile( + leading: const Icon(Icons.collections), + title: Text(collection.title), + subtitle: collection.childCount != null + ? Text('${collection.childCount} items') + : null, + onTap: () => Navigator.pop(context, collection.ratingKey), + ); + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(t.common.cancel), + ), + ], + ); + } +} + +/// Dialog to create a new collection +class _CreateCollectionDialog extends StatefulWidget { + @override + State<_CreateCollectionDialog> createState() => _CreateCollectionDialogState(); +} + +class _CreateCollectionDialogState extends State<_CreateCollectionDialog> { + final _controller = TextEditingController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(t.collections.createNewCollection), + content: TextField( + controller: _controller, + autofocus: true, + decoration: InputDecoration( + labelText: t.collections.collectionName, + hintText: t.collections.enterCollectionName, + ), + onSubmitted: (value) { + if (value.isNotEmpty) { + Navigator.pop(context, value); + } + }, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(t.common.cancel), + ), + TextButton( + onPressed: () { + if (_controller.text.isNotEmpty) { + Navigator.pop(context, _controller.text); + } + }, + child: Text(t.common.save), + ), + ], + ); + } +} diff --git a/lib/widgets/sort_bottom_sheet.dart b/lib/widgets/sort_bottom_sheet.dart index 6d5908fd..0ff146d4 100644 --- a/lib/widgets/sort_bottom_sheet.dart +++ b/lib/widgets/sort_bottom_sheet.dart @@ -39,6 +39,7 @@ class _SortBottomSheetState extends State { _currentDescending = descending; }); widget.onSortChanged(sort, descending); + Navigator.pop(context); } void _handleClear() { @@ -69,10 +70,10 @@ class _SortBottomSheetState extends State { ), child: Row( children: [ - const Expanded( + Expanded( child: Text( - 'Sort By', - style: TextStyle( + t.libraries.sortBy, + style: const TextStyle( fontSize: 20, fontWeight: FontWeight.bold, ), @@ -95,7 +96,7 @@ class _SortBottomSheetState extends State { groupValue: _currentSort, onChanged: (PlexSort? value) { if (value != null) { - _handleSortChange(value, value.defaultDirection == 'desc'); + _handleSortChange(value, value.isDefaultDescending); } }, child: ListView.builder( @@ -139,7 +140,7 @@ class _SortBottomSheetState extends State { onTap: () { _handleSortChange( sort, - sort.defaultDirection == 'desc', + sort.isDefaultDescending, ); }, ); From a952ab0a008485c4e826687911502adf16d12ebc Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 16 Nov 2025 22:42:04 +0100 Subject: [PATCH 25/29] refactor: deduplicate & other code quality fixes --- lib/mixins/library_tab_state.dart | 63 +++ lib/models/plex_hub.dart | 19 +- lib/models/plex_media_info.dart | 53 ++- .../base_media_list_detail_screen.dart | 93 +++++ lib/screens/collection_detail_screen.dart | 182 +++------ lib/screens/libraries_screen.dart | 369 ++++-------------- .../library_tabs/library_browse_tab.dart | 24 +- .../library_tabs/library_collections_tab.dart | 93 +---- .../library_tabs/library_playlists_tab.dart | 126 +++--- .../library_tabs/library_recommended_tab.dart | 66 +--- lib/screens/playlist_detail_screen.dart | 264 +++---------- lib/screens/playlists_screen.dart | 90 +---- lib/screens/server_selection_screen.dart | 8 + lib/utils/dialogs.dart | 33 ++ lib/utils/error_message_utils.dart | 29 ++ lib/widgets/adaptive_media_grid.dart | 76 ++++ lib/widgets/content_state_builder.dart | 89 +++++ .../sheets/audio_track_sheet.dart | 170 +++----- .../sheets/base_video_control_sheet.dart | 84 ++++ .../video_controls/sheets/chapter_sheet.dart | 264 +++++-------- .../sheets/playback_speed_sheet.dart | 102 ++--- .../sheets/sleep_timer_sheet.dart | 205 +--------- .../sheets/subtitle_track_sheet.dart | 247 +++++------- .../video_controls/sheets/version_sheet.dart | 91 ++--- .../sheets/video_settings_sheet.dart | 320 ++++----------- .../widgets/sleep_timer_active_status.dart | 95 +++++ .../widgets/sleep_timer_content.dart | 51 +++ .../widgets/sleep_timer_duration_list.dart | 85 ++++ 28 files changed, 1464 insertions(+), 1927 deletions(-) create mode 100644 lib/mixins/library_tab_state.dart create mode 100644 lib/screens/base_media_list_detail_screen.dart create mode 100644 lib/utils/dialogs.dart create mode 100644 lib/utils/error_message_utils.dart create mode 100644 lib/widgets/adaptive_media_grid.dart create mode 100644 lib/widgets/content_state_builder.dart create mode 100644 lib/widgets/video_controls/sheets/base_video_control_sheet.dart create mode 100644 lib/widgets/video_controls/widgets/sleep_timer_active_status.dart create mode 100644 lib/widgets/video_controls/widgets/sleep_timer_content.dart create mode 100644 lib/widgets/video_controls/widgets/sleep_timer_duration_list.dart diff --git a/lib/mixins/library_tab_state.dart b/lib/mixins/library_tab_state.dart new file mode 100644 index 00000000..426e1486 --- /dev/null +++ b/lib/mixins/library_tab_state.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import '../models/plex_library.dart'; +import '../models/plex_metadata.dart'; + +/// Mixin providing common state management for library tab screens +/// Standardizes loading, error handling, and lifecycle management +mixin LibraryTabStateMixin on State { + /// The list of items to display + List get items; + set items(List value); + + /// Whether data is currently loading + bool get isLoading; + set isLoading(bool value); + + /// Error message if loading failed + String? get errorMessage; + set errorMessage(String? value); + + /// The library being displayed + PlexLibrary get library; + + /// Load or reload the content + Future loadContent(); + + /// Common lifecycle: reload if library changed + @mustCallSuper + void didUpdateLibrary(PlexLibrary oldLibrary) { + if (oldLibrary.key != library.key) { + loadContent(); + } + } + + /// Helper to set loading state + void setLoadingState(bool loading) { + if (mounted) { + setState(() { + isLoading = loading; + }); + } + } + + /// Helper to set error state + void setErrorState(String? error) { + if (mounted) { + setState(() { + errorMessage = error; + isLoading = false; + }); + } + } + + /// Helper to set success state with items + void setSuccessState(List newItems) { + if (mounted) { + setState(() { + items = newItems; + isLoading = false; + errorMessage = null; + }); + } + } +} diff --git a/lib/models/plex_hub.dart b/lib/models/plex_hub.dart index e568718f..27fe3c86 100644 --- a/lib/models/plex_hub.dart +++ b/lib/models/plex_hub.dart @@ -23,9 +23,10 @@ class PlexHub { factory PlexHub.fromJson(Map json) { final metadataList = []; - // Hubs can contain either Metadata or Directory entries - if (json['Metadata'] != null) { - for (final item in json['Metadata'] as List) { + // Helper function to parse entries from a JSON list + void parseEntries(List? entries) { + if (entries == null) return; + for (final item in entries) { try { metadataList.add(PlexMetadata.fromJson(item)); } catch (e) { @@ -34,15 +35,9 @@ class PlexHub { } } - if (json['Directory'] != null) { - for (final item in json['Directory'] as List) { - try { - metadataList.add(PlexMetadata.fromJson(item)); - } catch (e) { - // Skip items that fail to parse - } - } - } + // Hubs can contain either Metadata or Directory entries + parseEntries(json['Metadata'] as List?); + parseEntries(json['Directory'] as List?); return PlexHub( hubKey: json['key'] as String? ?? '', diff --git a/lib/models/plex_media_info.dart b/lib/models/plex_media_info.dart index a6c2bfa7..6e05cf73 100644 --- a/lib/models/plex_media_info.dart +++ b/lib/models/plex_media_info.dart @@ -12,13 +12,40 @@ class PlexMediaInfo { }); } -class PlexAudioTrack { +/// Mixin for building track labels with a consistent pattern +mixin TrackLabelBuilder { + int get id; + int? get index; + String? get displayTitle; + String? get language; + + /// Builds a label from the given parts + /// If displayTitle is present, returns it + /// Otherwise, combines language and additional parts + String buildLabel(List additionalParts) { + if (displayTitle != null && displayTitle!.isNotEmpty) { + return displayTitle!; + } + final parts = []; + if (language != null && language!.isNotEmpty) { + parts.add(language!); + } + parts.addAll(additionalParts); + return parts.isEmpty ? 'Track ${index ?? id}' : parts.join(' · '); + } +} + +class PlexAudioTrack with TrackLabelBuilder { + @override final int id; + @override final int? index; final String? codec; + @override final String? language; final String? languageCode; final String? title; + @override final String? displayTitle; final int? channels; final bool selected; @@ -36,22 +63,24 @@ class PlexAudioTrack { }); String get label { - if (displayTitle != null) return displayTitle!; - final parts = []; - if (language != null) parts.add(language!); - if (codec != null) parts.add(codec!.toUpperCase()); - if (channels != null) parts.add('${channels!}ch'); - return parts.isEmpty ? 'Track ${index ?? id}' : parts.join(' · '); + final additionalParts = []; + if (codec != null) additionalParts.add(codec!.toUpperCase()); + if (channels != null) additionalParts.add('${channels!}ch'); + return buildLabel(additionalParts); } } -class PlexSubtitleTrack { +class PlexSubtitleTrack with TrackLabelBuilder { + @override final int id; + @override final int? index; final String? codec; + @override final String? language; final String? languageCode; final String? title; + @override final String? displayTitle; final bool selected; final bool forced; @@ -71,11 +100,9 @@ class PlexSubtitleTrack { }); String get label { - if (displayTitle != null) return displayTitle!; - final parts = []; - if (language != null) parts.add(language!); - if (forced) parts.add('Forced'); - return parts.isEmpty ? 'Track ${index ?? id}' : parts.join(' · '); + final additionalParts = []; + if (forced) additionalParts.add('Forced'); + return buildLabel(additionalParts); } /// Returns true if this subtitle track is an external file (sidecar subtitle) diff --git a/lib/screens/base_media_list_detail_screen.dart b/lib/screens/base_media_list_detail_screen.dart new file mode 100644 index 00000000..c0a4d63f --- /dev/null +++ b/lib/screens/base_media_list_detail_screen.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import '../client/plex_client.dart'; +import '../models/plex_metadata.dart'; +import '../utils/provider_extensions.dart'; +import '../utils/collection_playlist_play_helper.dart'; +import '../mixins/refreshable.dart'; +import '../mixins/item_updatable.dart'; + +/// Abstract base class for screens displaying media lists (collections/playlists) +/// Provides common state management and playback functionality +abstract class BaseMediaListDetailScreen + extends State with Refreshable, ItemUpdatable { + // State properties - concrete implementations to avoid duplication + List _items = []; + bool _isLoading = false; + String? _errorMessage; + + List get items => _items; + set items(List value) => _items = value; + + bool get isLoading => _isLoading; + set isLoading(bool value) => _isLoading = value; + + String? get errorMessage => _errorMessage; + set errorMessage(String? value) => _errorMessage = value; + + @override + PlexClient get client => context.clientSafe; + + /// The media item being displayed (collection or playlist) + dynamic get mediaItem; + + /// Title to display in app bar + String get title; + + /// Message to show when list is empty + String get emptyMessage; + + @override + void initState() { + super.initState(); + loadItems(); + } + + /// Load or reload the items (subclasses implement this) + Future loadItems(); + + /// Play all items in the list + Future playItems() => _playWithShuffle(false); + + /// Shuffle play all items in the list + Future shufflePlayItems() => _playWithShuffle(true); + + /// Internal helper to play items with optional shuffle + Future _playWithShuffle(bool shuffle) async { + if (items.isEmpty) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(emptyMessage)), + ); + } + return; + } + + final clientProvider = context.plexClient; + final client = clientProvider.client; + if (client == null) return; + + await playCollectionOrPlaylist( + context: context, + client: client, + item: mediaItem, + shuffle: shuffle, + ); + } + + @override + void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) { + if (mounted) { + setState(() { + final index = items.indexWhere((item) => item.ratingKey == ratingKey); + if (index != -1) { + items[index] = updatedMetadata; + } + }); + } + } + + @override + void refresh() { + loadItems(); + } +} diff --git a/lib/screens/collection_detail_screen.dart b/lib/screens/collection_detail_screen.dart index 18bdc683..376fd69d 100644 --- a/lib/screens/collection_detail_screen.dart +++ b/lib/screens/collection_detail_screen.dart @@ -1,18 +1,15 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import '../client/plex_client.dart'; import '../models/plex_metadata.dart'; import '../providers/settings_provider.dart'; -import '../services/settings_service.dart'; -import '../utils/provider_extensions.dart'; import '../utils/app_logger.dart'; -import '../utils/collection_playlist_play_helper.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'; import '../utils/grid_size_calculator.dart'; +import '../utils/dialogs.dart'; +import '../utils/provider_extensions.dart'; +import 'base_media_list_detail_screen.dart'; /// Screen to display the contents of a collection class CollectionDetailScreen extends StatefulWidget { @@ -24,112 +21,49 @@ class CollectionDetailScreen extends StatefulWidget { State createState() => _CollectionDetailScreenState(); } -class _CollectionDetailScreenState extends State - with Refreshable, ItemUpdatable { +class _CollectionDetailScreenState + extends BaseMediaListDetailScreen { @override - PlexClient get client => context.clientSafe; - - List _items = []; - bool _isLoading = false; - String? _errorMessage; + PlexMetadata get mediaItem => widget.collection; @override - void initState() { - super.initState(); - _loadCollectionItems(); - } + String get title => widget.collection.title; - Future _loadCollectionItems() async { - setState(() { - _isLoading = true; - _errorMessage = null; - }); + @override + String get emptyMessage => t.collections.empty; + + @override + Future loadItems() async { + if (mounted) { + setState(() { + isLoading = true; + errorMessage = null; + }); + } try { - final clientProvider = context.plexClient; - final client = clientProvider.client; - if (client == null) { - throw Exception(t.errors.noClientAvailable); + final client = this.client; + final newItems = await client.getCollectionItems(widget.collection.ratingKey); + + if (mounted) { + setState(() { + items = newItems; + isLoading = false; + }); } - final items = await client.getCollectionItems(widget.collection.ratingKey); - - setState(() { - _items = items; - _isLoading = false; - }); - appLogger.d( - 'Loaded ${items.length} items for collection: ${widget.collection.title}', + 'Loaded ${newItems.length} items for collection: ${widget.collection.title}', ); } catch (e) { appLogger.e('Failed to load collection items', error: e); - setState(() { - _errorMessage = t.collections.failedToLoadItems( - error: e.toString(), - ); - _isLoading = false; - }); - } - } - - @override - void refresh() { - _loadCollectionItems(); - } - - @override - void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) { - setState(() { - final index = _items.indexWhere((item) => item.ratingKey == ratingKey); - if (index != -1) { - _items[index] = updatedMetadata; - } - }); - } - - Future _playCollection() async { - if (_items.isEmpty) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(t.collections.empty)), - ); + setState(() { + errorMessage = t.collections.failedToLoadItems(error: e.toString()); + isLoading = false; + }); } - return; } - - final clientProvider = context.plexClient; - final client = clientProvider.client; - if (client == null) return; - - await playCollectionOrPlaylist( - context: context, - client: client, - item: widget.collection, - shuffle: false, - ); - } - - Future _shufflePlayCollection() async { - if (_items.isEmpty) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(t.collections.empty)), - ); - } - return; - } - - final clientProvider = context.plexClient; - final client = clientProvider.client; - if (client == null) return; - - await playCollectionOrPlaylist( - context: context, - client: client, - item: widget.collection, - shuffle: true, - ); } Future _deleteCollection() async { @@ -137,8 +71,8 @@ class _CollectionDetailScreenState extends State int? sectionId = widget.collection.librarySectionID; // If collection doesn't have it, try to get it from loaded items - if (sectionId == null && _items.isNotEmpty) { - sectionId = _items.first.librarySectionID; + if (sectionId == null && items.isNotEmpty) { + sectionId = items.first.librarySectionID; } if (sectionId == null) { @@ -151,28 +85,14 @@ class _CollectionDetailScreenState extends State } // Show confirmation dialog - final confirmed = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(t.collections.deleteCollection), - content: Text( - t.collections.deleteConfirm(title: widget.collection.title), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: Text(t.common.cancel), - ), - TextButton( - onPressed: () => Navigator.pop(context, true), - style: TextButton.styleFrom(foregroundColor: Colors.red), - child: Text(t.common.delete), - ), - ], - ), + final confirmed = await showDeleteConfirmation( + context, + title: t.collections.deleteCollection, + message: t.collections.deleteConfirm(title: widget.collection.title), ); if (confirmed != true) return; + if (!mounted) return; try { final clientProvider = context.plexClient; @@ -184,6 +104,8 @@ class _CollectionDetailScreenState extends State widget.collection.ratingKey, ); + if (!mounted) return; + if (mounted) { if (success) { ScaffoldMessenger.of(context).showSnackBar( @@ -221,18 +143,18 @@ class _CollectionDetailScreenState extends State pinned: true, actions: [ // Play button - if (_items.isNotEmpty) + if (items.isNotEmpty) IconButton( icon: const Icon(Icons.play_arrow), tooltip: t.discover.play, - onPressed: _playCollection, + onPressed: playItems, ), // Shuffle button - if (_items.isNotEmpty) + if (items.isNotEmpty) IconButton( icon: const Icon(Icons.shuffle), tooltip: t.common.shuffle, - onPressed: _shufflePlayCollection, + onPressed: shufflePlayItems, ), // Delete button IconButton( @@ -243,7 +165,7 @@ class _CollectionDetailScreenState extends State ), ], ), - if (_errorMessage != null) + if (errorMessage != null) SliverFillRemaining( child: Center( child: Column( @@ -255,21 +177,21 @@ class _CollectionDetailScreenState extends State color: Colors.red, ), const SizedBox(height: 16), - Text(_errorMessage!), + Text(errorMessage!), const SizedBox(height: 16), ElevatedButton( - onPressed: _loadCollectionItems, + onPressed: loadItems, child: Text(t.common.retry), ), ], ), ), ) - else if (_items.isEmpty && _isLoading) + else if (items.isEmpty && isLoading) const SliverFillRemaining( child: Center(child: CircularProgressIndicator()), ) - else if (_items.isEmpty) + else if (items.isEmpty) SliverFillRemaining( child: Center( child: Text(t.collections.noItems), @@ -292,16 +214,16 @@ class _CollectionDetailScreenState extends State ), delegate: SliverChildBuilderDelegate( (context, index) { - final item = _items[index]; + final item = items[index]; return MediaCard( key: Key(item.ratingKey), item: item, onRefresh: updateItem, collectionId: widget.collection.ratingKey, - onListRefresh: _loadCollectionItems, + onListRefresh: loadItems, ); }, - childCount: _items.length, + childCount: items.length, ), ); }, diff --git a/lib/screens/libraries_screen.dart b/lib/screens/libraries_screen.dart index 532bed37..19c32668 100644 --- a/lib/screens/libraries_screen.dart +++ b/lib/screens/libraries_screen.dart @@ -4,24 +4,18 @@ import 'package:dio/dio.dart'; import '../client/plex_client.dart'; import '../models/plex_library.dart'; import '../models/plex_metadata.dart'; -import '../models/plex_filter.dart'; import '../models/plex_sort.dart'; -import '../providers/plex_client_provider.dart'; -import '../providers/settings_provider.dart'; import '../providers/hidden_libraries_provider.dart'; import '../utils/provider_extensions.dart'; import '../utils/app_logger.dart'; -import '../widgets/media_card.dart'; import '../widgets/desktop_app_bar.dart'; -import '../widgets/app_bar_back_button.dart'; import '../widgets/context_menu_wrapper.dart'; -import '../widgets/filters_bottom_sheet.dart'; -import '../widgets/sort_bottom_sheet.dart'; import '../services/storage_service.dart'; import '../mixins/refreshable.dart'; import '../mixins/item_updatable.dart'; import '../theme/theme_helper.dart'; import '../i18n/strings.g.dart'; +import '../utils/error_message_utils.dart'; import 'library_tabs/library_browse_tab.dart'; import 'library_tabs/library_recommended_tab.dart'; import 'library_tabs/library_collections_tab.dart'; @@ -53,14 +47,9 @@ class _LibrariesScreenState extends State String? _selectedLibraryKey; bool _isInitialLoad = true; - // Legacy fields kept for compatibility with existing methods - // TODO: Clean up after refactoring _loadLibraryContent - List _filters = []; - List _sortOptions = []; Map _selectedFilters = {}; PlexSort? _selectedSort; bool _isSortDescending = false; - bool _isLoadingItems = false; List _items = []; int _currentPage = 0; bool _hasMoreItems = true; @@ -95,28 +84,18 @@ class _LibrariesScreenState extends State super.dispose(); } + void _updateState(VoidCallback fn) { + if (!mounted) return; + setState(fn); + } + /// Helper method to get user-friendly error message from exception String _getErrorMessage(dynamic error, String context) { if (error is DioException) { - // Other Dio errors - switch (error.type) { - case DioExceptionType.connectionTimeout: - case DioExceptionType.receiveTimeout: - return t.errors.connectionTimeout(context: context); - case DioExceptionType.connectionError: - return t.errors.connectionFailed; - default: - appLogger.e('Error loading $context', error: error); - return t.errors.failedToLoad( - context: context, - error: error.message ?? 'Unknown error', - ); - } + return mapDioErrorToMessage(error, context: context); } - // Generic error - appLogger.e('Unexpected error in $context', error: error); - return t.errors.failedToLoad(context: context, error: error.toString()); + return mapUnexpectedErrorToMessage(error, context: context); } Future _loadLibraries() async { @@ -154,7 +133,7 @@ class _LibrariesScreenState extends State savedOrder, ); - setState(() { + _updateState(() { _allLibraries = orderedLibraries; // Store all libraries with ordering applied _isLoadingLibraries = false; @@ -187,7 +166,7 @@ class _LibrariesScreenState extends State libraryKeyToLoad = visibleLibraries.first.key; } - if (libraryKeyToLoad != null) { + if (libraryKeyToLoad != null && mounted) { final savedFilters = storage.getLibraryFilters(sectionId: libraryKeyToLoad); if (savedFilters.isNotEmpty) { @@ -197,7 +176,7 @@ class _LibrariesScreenState extends State } } } catch (e) { - setState(() { + _updateState(() { _errorMessage = _getErrorMessage(e, 'libraries'); _isLoadingLibraries = false; }); @@ -267,16 +246,14 @@ class _LibrariesScreenState extends State final clientProvider = context.plexClient; final client = clientProvider.client; if (client == null) { - setState(() { + _updateState(() { _errorMessage = t.errors.noClientAvailable; - _isLoadingItems = false; }); return; } - setState(() { + _updateState(() { _selectedLibraryKey = libraryKey; - _isLoadingItems = true; _errorMessage = null; // Only clear filters when explicitly changing library (not on initial load) if (isChangingLibrary) { @@ -296,7 +273,7 @@ class _LibrariesScreenState extends State // Restore saved tab index for this library final savedTabIndex = storage.getLibraryTab(libraryKey); if (savedTabIndex != null && savedTabIndex >= 0 && savedTabIndex < 4) { - setState(() { + _updateState(() { _tabController.index = savedTabIndex; }); } @@ -315,24 +292,17 @@ class _LibrariesScreenState extends State final currentRequestId = ++_requestId; // Reset pagination state - setState(() { + _updateState(() { _currentPage = 0; _hasMoreItems = true; _items = []; }); try { - // Load filters and sort options for the new library - _loadFilters(libraryKey); + // Load sort options for the new library await _loadSortOptions(libraryKey); - // Add sort parameter to filters if selected - final filtersWithSort = Map.from(_selectedFilters); - if (_selectedSort != null) { - filtersWithSort['sort'] = _selectedSort!.getSortKey( - descending: _isSortDescending, - ); - } + final filtersWithSort = _buildFiltersWithSort(); // Load pages sequentially await _loadAllPagesSequentially( @@ -347,9 +317,8 @@ class _LibrariesScreenState extends State return; } - setState(() { + _updateState(() { _errorMessage = _getErrorMessage(e, 'library content'); - _isLoadingItems = false; }); } } @@ -376,15 +345,10 @@ class _LibrariesScreenState extends State return; // Request was superseded } - setState(() { + _updateState(() { _items.addAll(items); _currentPage++; _hasMoreItems = items.length >= _pageSize; - - // Mark as not loading if this is the last page - if (!_hasMoreItems) { - _isLoadingItems = false; - } }); } catch (e) { // Check if it's a cancellation @@ -393,8 +357,7 @@ class _LibrariesScreenState extends State } // For other errors, update state and rethrow - setState(() { - _isLoadingItems = false; + _updateState(() { _hasMoreItems = false; }); rethrow; @@ -402,26 +365,6 @@ class _LibrariesScreenState extends State } } - Future _loadFilters(String libraryKey) async { - try { - final clientProvider = context.plexClient; - final client = clientProvider.client; - if (client == null) { - throw Exception(t.errors.noClientAvailable); - } - - final filters = await client.getLibraryFilters(libraryKey); - setState(() { - _filters = filters; - }); - } catch (e) { - appLogger.w('Failed to load filters', error: e); - setState(() { - _filters = []; - }); - } - } - Future _loadSortOptions(String libraryKey) async { try { final clientProvider = context.plexClient; @@ -455,85 +398,26 @@ class _LibrariesScreenState extends State savedSort = sortOptions.first; } - setState(() { - _sortOptions = sortOptions; + _updateState(() { _selectedSort = savedSort; _isSortDescending = descending; }); } catch (e) { - setState(() { - _sortOptions = []; + _updateState(() { _selectedSort = null; _isSortDescending = false; }); } } - Future _applyFilters() async { - // Cancel any existing requests - _cancelToken?.cancel(); - _cancelToken = CancelToken(); - final currentRequestId = ++_requestId; - - setState(() { - _isLoadingItems = true; - _errorMessage = null; - _currentPage = 0; - _hasMoreItems = true; - _items = []; - }); - - try { - final clientProvider = context.plexClient; - final client = clientProvider.client; - if (client == null) { - throw Exception(t.errors.noClientAvailable); - } - - // Add sort parameter to filters if selected - final filtersWithSort = Map.from(_selectedFilters); - if (_selectedSort != null) { - filtersWithSort['sort'] = _selectedSort!.getSortKey( - descending: _isSortDescending, - ); - } - - // Load pages sequentially - await _loadAllPagesSequentially( - _selectedLibraryKey!, - filtersWithSort, - currentRequestId, - client, + Map _buildFiltersWithSort() { + final filtersWithSort = Map.from(_selectedFilters); + if (_selectedSort != null) { + filtersWithSort['sort'] = _selectedSort!.getSortKey( + descending: _isSortDescending, ); - } catch (e) { - // Ignore cancellation errors - if (e is DioException && e.type == DioExceptionType.cancel) { - return; - } - - setState(() { - _errorMessage = t.messages.errorLoading(error: e.toString()); - _isLoadingItems = false; - }); } - } - - Future _applySort(PlexSort sort, bool descending) async { - setState(() { - _selectedSort = sort; - _isSortDescending = descending; - }); - - // Save sort preference for this library - final storage = await StorageService.getInstance(); - await storage.saveLibrarySort( - _selectedLibraryKey!, - sort.key, - descending: descending, - ); - - // Reload content with new sort - _applyFilters(); + return filtersWithSort; } @override @@ -554,27 +438,27 @@ class _LibrariesScreenState extends State void _refreshCurrentTab() { switch (_tabController.index) { case 0: // Recommended tab - final state = _recommendedTabKey.currentState; - if (state is Refreshable) { - (state as Refreshable).refresh(); + final refreshable = _recommendedTabKey.currentState; + if (refreshable is Refreshable) { + (refreshable as Refreshable).refresh(); } break; case 1: // Browse tab - final state = _browseTabKey.currentState; - if (state is Refreshable) { - (state as Refreshable).refresh(); + final refreshable = _browseTabKey.currentState; + if (refreshable is Refreshable) { + (refreshable as Refreshable).refresh(); } break; case 2: // Collections tab - final state = _collectionsTabKey.currentState; - if (state is Refreshable) { - (state as Refreshable).refresh(); + final refreshable = _collectionsTabKey.currentState; + if (refreshable is Refreshable) { + (refreshable as Refreshable).refresh(); } break; case 3: // Playlists tab - final state = _playlistsTabKey.currentState; - if (state is Refreshable) { - (state as Refreshable).refresh(); + final refreshable = _playlistsTabKey.currentState; + if (refreshable is Refreshable) { + (refreshable as Refreshable).refresh(); } break; } @@ -713,7 +597,13 @@ class _LibrariesScreenState extends State ); } - Future _scanLibrary(PlexLibrary library) async { + Future _performLibraryAction({ + required PlexLibrary library, + required Future Function(PlexClient client) action, + required String progressMessage, + required String successMessage, + required String Function(Object error) failureMessage, + }) async { try { final clientProvider = context.plexClient; final client = clientProvider.client; @@ -721,32 +611,31 @@ class _LibrariesScreenState extends State throw Exception(t.errors.noClientAvailable); } - // Show progress indicator if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(t.messages.libraryScanning(title: library.title)), + content: Text(progressMessage), duration: const Duration(seconds: 2), ), ); } - await client.scanLibrary(library.key); + await action(client); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(t.messages.libraryScanStarted(title: library.title)), + content: Text(successMessage), duration: const Duration(seconds: 3), ), ); } } catch (e) { - appLogger.e('Failed to scan library', error: e); + appLogger.e('Library action failed', error: e); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(t.messages.libraryScanFailed(error: e.toString())), + content: Text(failureMessage(e)), backgroundColor: Colors.red, duration: const Duration(seconds: 3), ), @@ -755,134 +644,46 @@ class _LibrariesScreenState extends State } } + Future _scanLibrary(PlexLibrary library) async { + return _performLibraryAction( + library: library, + action: (client) => client.scanLibrary(library.key), + progressMessage: t.messages.libraryScanning(title: library.title), + successMessage: t.messages.libraryScanStarted(title: library.title), + failureMessage: (error) => + t.messages.libraryScanFailed(error: error.toString()), + ); + } + Future _refreshLibraryMetadata(PlexLibrary library) async { - try { - final clientProvider = context.plexClient; - final client = clientProvider.client; - if (client == null) { - throw Exception(t.errors.noClientAvailable); - } - - // Show progress indicator - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(t.messages.metadataRefreshing(title: library.title)), - duration: const Duration(seconds: 2), - ), - ); - } - - await client.refreshLibraryMetadata(library.key); - - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - t.messages.metadataRefreshStarted(title: library.title), - ), - duration: const Duration(seconds: 3), - ), - ); - } - } catch (e) { - appLogger.e('Failed to refresh library metadata', error: e); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - t.messages.metadataRefreshFailed(error: e.toString()), - ), - backgroundColor: Colors.red, - duration: const Duration(seconds: 3), - ), - ); - } - } + return _performLibraryAction( + library: library, + action: (client) => client.refreshLibraryMetadata(library.key), + progressMessage: t.messages.metadataRefreshing(title: library.title), + successMessage: t.messages.metadataRefreshStarted(title: library.title), + failureMessage: (error) => + t.messages.metadataRefreshFailed(error: error.toString()), + ); } Future _emptyLibraryTrash(PlexLibrary library) async { - try { - final clientProvider = context.plexClient; - final client = clientProvider.client; - if (client == null) { - throw Exception(t.errors.noClientAvailable); - } - - // Show progress indicator - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(t.libraries.emptyingTrash(title: library.title)), - duration: const Duration(seconds: 2), - ), - ); - } - - await client.emptyLibraryTrash(library.key); - - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(t.libraries.trashEmptied(title: library.title)), - duration: const Duration(seconds: 3), - ), - ); - } - } catch (e) { - appLogger.e('Failed to empty library trash', error: e); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(t.libraries.failedToEmptyTrash(error: e)), - backgroundColor: Colors.red, - duration: const Duration(seconds: 3), - ), - ); - } - } + return _performLibraryAction( + library: library, + action: (client) => client.emptyLibraryTrash(library.key), + progressMessage: t.libraries.emptyingTrash(title: library.title), + successMessage: t.libraries.trashEmptied(title: library.title), + failureMessage: (error) => t.libraries.failedToEmptyTrash(error: error), + ); } Future _analyzeLibrary(PlexLibrary library) async { - try { - final clientProvider = context.plexClient; - final client = clientProvider.client; - if (client == null) { - throw Exception(t.errors.noClientAvailable); - } - - // Show progress indicator - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(t.libraries.analyzing(title: library.title)), - duration: const Duration(seconds: 2), - ), - ); - } - - await client.analyzeLibrary(library.key); - - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(t.libraries.analysisStarted(title: library.title)), - duration: const Duration(seconds: 3), - ), - ); - } - } catch (e) { - appLogger.e('Failed to analyze library', error: e); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(t.libraries.failedToAnalyze(error: e)), - backgroundColor: Colors.red, - duration: const Duration(seconds: 3), - ), - ); - } - } + return _performLibraryAction( + library: library, + action: (client) => client.analyzeLibrary(library.key), + progressMessage: t.libraries.analyzing(title: library.title), + successMessage: t.libraries.analysisStarted(title: library.title), + failureMessage: (error) => t.libraries.failedToAnalyze(error: error), + ); } Widget _buildTabChip(String label, int index) { diff --git a/lib/screens/library_tabs/library_browse_tab.dart b/lib/screens/library_tabs/library_browse_tab.dart index b2234110..2fd87712 100644 --- a/lib/screens/library_tabs/library_browse_tab.dart +++ b/lib/screens/library_tabs/library_browse_tab.dart @@ -9,15 +9,14 @@ import '../../models/plex_sort.dart'; import '../../providers/plex_client_provider.dart'; import '../../providers/settings_provider.dart'; import '../../utils/provider_extensions.dart'; -import '../../utils/app_logger.dart'; +import '../../utils/error_message_utils.dart'; import '../../utils/grid_size_calculator.dart'; import '../../widgets/media_card.dart'; -import '../../widgets/app_bar_back_button.dart'; import '../../widgets/folder_tree_view.dart'; import '../../widgets/filters_bottom_sheet.dart'; import '../../widgets/sort_bottom_sheet.dart'; import '../../services/storage_service.dart'; -import '../../services/settings_service.dart' show LibraryDensity, ViewMode; +import '../../services/settings_service.dart' show ViewMode; import '../../mixins/item_updatable.dart'; import '../../mixins/refreshable.dart'; import '../../i18n/strings.g.dart'; @@ -301,24 +300,11 @@ class _LibraryBrowseTabState extends State String _getErrorMessage(dynamic error) { if (error is DioException) { - switch (error.type) { - case DioExceptionType.connectionTimeout: - case DioExceptionType.receiveTimeout: - return t.errors.connectionTimeout(context: t.libraries.content); - case DioExceptionType.connectionError: - return t.errors.connectionFailed; - default: - appLogger.e('Error loading library content', error: error); - return t.errors.failedToLoad( - context: t.libraries.content, - error: error.message ?? t.common.unknown, - ); - } + return mapDioErrorToMessage(error, context: t.libraries.content); } - appLogger.e('Unexpected error loading library content', error: error); - return t.errors.failedToLoad( + return mapUnexpectedErrorToMessage( + error, context: t.libraries.content, - error: error.toString(), ); } diff --git a/lib/screens/library_tabs/library_collections_tab.dart b/lib/screens/library_tabs/library_collections_tab.dart index 37207f84..f5e0f6a4 100644 --- a/lib/screens/library_tabs/library_collections_tab.dart +++ b/lib/screens/library_tabs/library_collections_tab.dart @@ -4,14 +4,12 @@ import 'package:provider/provider.dart'; import '../../models/plex_library.dart'; import '../../models/plex_metadata.dart'; import '../../providers/plex_client_provider.dart'; -import '../../providers/settings_provider.dart'; import '../../utils/app_logger.dart'; import '../../utils/library_refresh_notifier.dart'; -import '../../widgets/media_card.dart'; -import '../../services/settings_service.dart' show LibraryDensity, ViewMode; -import '../../utils/grid_size_calculator.dart'; import '../../i18n/strings.g.dart'; import '../../mixins/refreshable.dart'; +import '../../widgets/content_state_builder.dart'; +import '../../widgets/adaptive_media_grid.dart'; /// Collections tab for library screen /// Shows collections for the current library @@ -113,82 +111,19 @@ class _LibraryCollectionsTabState extends State Widget build(BuildContext context) { super.build(context); // Required for AutomaticKeepAliveClientMixin - if (_isLoading && _collections.isEmpty) { - return const Center(child: CircularProgressIndicator()); - } - - if (_errorMessage != null && _collections.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.error_outline, size: 48, color: Colors.red), - const SizedBox(height: 16), - Text(_errorMessage!), - const SizedBox(height: 16), - ElevatedButton( - onPressed: _loadCollections, - child: Text(t.common.retry), - ), - ], + return ContentStateBuilder( + isLoading: _isLoading, + errorMessage: _errorMessage, + items: _collections, + emptyIcon: Icons.collections, + emptyMessage: t.libraries.noCollections, + onRetry: _loadCollections, + builder: (items) => RefreshIndicator( + onRefresh: _loadCollections, + child: AdaptiveMediaGrid( + items: items, + onRefresh: _loadCollections, ), - ); - } - - if (_collections.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.collections, size: 64, color: Colors.grey), - const SizedBox(height: 16), - Text(t.libraries.noCollections), - ], - ), - ); - } - - return RefreshIndicator( - onRefresh: _loadCollections, - child: Consumer( - builder: (context, settingsProvider, child) { - if (settingsProvider.viewMode == ViewMode.list) { - return ListView.builder( - padding: const EdgeInsets.fromLTRB(8, 8, 8, 8), - itemCount: _collections.length, - itemBuilder: (context, index) { - final collection = _collections[index]; - return MediaCard( - key: Key(collection.ratingKey), - item: collection, - onListRefresh: _loadCollections, - ); - }, - ); - } else { - return GridView.builder( - padding: const EdgeInsets.fromLTRB(8, 8, 8, 8), - gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent( - context, - settingsProvider.libraryDensity, - ), - childAspectRatio: 2 / 3.3, - crossAxisSpacing: 0, - mainAxisSpacing: 0, - ), - itemCount: _collections.length, - itemBuilder: (context, index) { - final collection = _collections[index]; - return MediaCard( - key: Key(collection.ratingKey), - item: collection, - onListRefresh: _loadCollections, - ); - }, - ); - } - }, ), ); } diff --git a/lib/screens/library_tabs/library_playlists_tab.dart b/lib/screens/library_tabs/library_playlists_tab.dart index f2d0e2b4..8ef5156b 100644 --- a/lib/screens/library_tabs/library_playlists_tab.dart +++ b/lib/screens/library_tabs/library_playlists_tab.dart @@ -7,11 +7,12 @@ import '../../providers/plex_client_provider.dart'; import '../../providers/settings_provider.dart'; import '../../utils/app_logger.dart'; import '../../utils/library_refresh_notifier.dart'; -import '../../services/settings_service.dart' show LibraryDensity, ViewMode; +import '../../services/settings_service.dart' show ViewMode; import '../../utils/grid_size_calculator.dart'; import '../../widgets/media_card.dart'; import '../../i18n/strings.g.dart'; import '../../mixins/refreshable.dart'; +import '../../widgets/content_state_builder.dart'; /// Playlists tab for library screen /// Shows playlists that contain items from the current library @@ -117,82 +118,55 @@ class _LibraryPlaylistsTabState extends State Widget build(BuildContext context) { super.build(context); // Required for AutomaticKeepAliveClientMixin - if (_isLoading && _playlists.isEmpty) { - return const Center(child: CircularProgressIndicator()); - } - - if (_errorMessage != null && _playlists.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.error_outline, size: 48, color: Colors.red), - const SizedBox(height: 16), - Text(_errorMessage!), - const SizedBox(height: 16), - ElevatedButton( - onPressed: _loadPlaylists, - child: Text(t.common.retry), - ), - ], - ), - ); - } - - if (_playlists.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.playlist_play, size: 64, color: Colors.grey), - const SizedBox(height: 16), - Text(t.playlists.noPlaylists), - ], - ), - ); - } - - return RefreshIndicator( - onRefresh: _loadPlaylists, - child: Consumer( - builder: (context, settingsProvider, child) { - if (settingsProvider.viewMode == ViewMode.list) { - return ListView.builder( - padding: const EdgeInsets.fromLTRB(8, 8, 8, 8), - itemCount: _playlists.length, - itemBuilder: (context, index) { - final playlist = _playlists[index]; - return MediaCard( - key: Key(playlist.ratingKey), - item: playlist, - onListRefresh: _loadPlaylists, - ); - }, - ); - } else { - return GridView.builder( - padding: const EdgeInsets.fromLTRB(8, 8, 8, 8), - gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent( - context, - settingsProvider.libraryDensity, + return ContentStateBuilder( + isLoading: _isLoading, + errorMessage: _errorMessage, + items: _playlists, + emptyIcon: Icons.playlist_play, + emptyMessage: t.playlists.noPlaylists, + onRetry: _loadPlaylists, + builder: (items) => RefreshIndicator( + onRefresh: _loadPlaylists, + child: Consumer( + builder: (context, settingsProvider, child) { + if (settingsProvider.viewMode == ViewMode.list) { + return ListView.builder( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 8), + itemCount: items.length, + itemBuilder: (context, index) { + final playlist = items[index]; + return MediaCard( + key: Key(playlist.ratingKey), + item: playlist, + onListRefresh: _loadPlaylists, + ); + }, + ); + } else { + return GridView.builder( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 8), + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent( + context, + settingsProvider.libraryDensity, + ), + childAspectRatio: 2 / 3.3, + crossAxisSpacing: 0, + mainAxisSpacing: 0, ), - childAspectRatio: 2 / 3.3, - crossAxisSpacing: 0, - mainAxisSpacing: 0, - ), - itemCount: _playlists.length, - itemBuilder: (context, index) { - final playlist = _playlists[index]; - return MediaCard( - key: Key(playlist.ratingKey), - item: playlist, - onListRefresh: _loadPlaylists, - ); - }, - ); - } - }, + itemCount: items.length, + itemBuilder: (context, index) { + final playlist = items[index]; + return MediaCard( + key: Key(playlist.ratingKey), + item: playlist, + onListRefresh: _loadPlaylists, + ); + }, + ); + } + }, + ), ), ); } diff --git a/lib/screens/library_tabs/library_recommended_tab.dart b/lib/screens/library_tabs/library_recommended_tab.dart index 53b82423..84ae7c77 100644 --- a/lib/screens/library_tabs/library_recommended_tab.dart +++ b/lib/screens/library_tabs/library_recommended_tab.dart @@ -7,6 +7,7 @@ import '../../utils/app_logger.dart'; import '../../widgets/hub_section.dart'; import '../../i18n/strings.g.dart'; import '../../mixins/refreshable.dart'; +import '../../widgets/content_state_builder.dart'; /// Recommended tab for library screen /// Shows library-specific hubs and recommendations @@ -109,53 +110,26 @@ class _LibraryRecommendedTabState extends State Widget build(BuildContext context) { super.build(context); // Required for AutomaticKeepAliveClientMixin - if (_isLoading && _hubs.isEmpty) { - return const Center(child: CircularProgressIndicator()); - } - - if (_errorMessage != null && _hubs.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.error_outline, size: 48, color: Colors.red), - const SizedBox(height: 16), - Text(_errorMessage!), - const SizedBox(height: 16), - ElevatedButton( - onPressed: _loadHubs, - child: Text(t.common.retry), - ), - ], + return ContentStateBuilder( + isLoading: _isLoading, + errorMessage: _errorMessage, + items: _hubs, + emptyIcon: Icons.recommend, + emptyMessage: t.libraries.noRecommendations, + onRetry: _loadHubs, + builder: (items) => RefreshIndicator( + onRefresh: _loadHubs, + child: ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: items.length, + itemBuilder: (context, index) { + final hub = items[index]; + return HubSection( + hub: hub, + icon: _getHubIcon(hub), + ); + }, ), - ); - } - - if (_hubs.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.recommend, size: 64, color: Colors.grey), - const SizedBox(height: 16), - Text(t.libraries.noRecommendations), - ], - ), - ); - } - - return RefreshIndicator( - onRefresh: _loadHubs, - child: ListView.builder( - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: _hubs.length, - itemBuilder: (context, index) { - final hub = _hubs[index]; - return HubSection( - hub: hub, - icon: _getHubIcon(hub), - ); - }, ), ); } diff --git a/lib/screens/playlist_detail_screen.dart b/lib/screens/playlist_detail_screen.dart index e75851a2..5af3ed2f 100644 --- a/lib/screens/playlist_detail_screen.dart +++ b/lib/screens/playlist_detail_screen.dart @@ -1,21 +1,18 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import '../client/plex_client.dart'; import '../models/plex_playlist.dart'; -import '../models/plex_metadata.dart'; import '../providers/settings_provider.dart'; import '../providers/playback_state_provider.dart'; -import '../services/settings_service.dart'; -import '../utils/provider_extensions.dart'; import '../utils/app_logger.dart'; -import '../utils/collection_playlist_play_helper.dart'; +import '../utils/provider_extensions.dart'; import '../utils/video_player_navigation.dart'; +import '../utils/grid_size_calculator.dart'; import '../widgets/media_card.dart'; import '../widgets/playlist_item_card.dart'; import '../widgets/desktop_app_bar.dart'; -import '../mixins/refreshable.dart'; -import '../mixins/item_updatable.dart'; import '../i18n/strings.g.dart'; +import '../utils/dialogs.dart'; +import 'base_media_list_detail_screen.dart'; /// Screen to display the contents of a playlist class PlaylistDetailScreen extends StatefulWidget { @@ -27,71 +24,56 @@ class PlaylistDetailScreen extends StatefulWidget { State createState() => _PlaylistDetailScreenState(); } -class _PlaylistDetailScreenState extends State - with Refreshable, ItemUpdatable { +class _PlaylistDetailScreenState + extends BaseMediaListDetailScreen { @override - PlexClient get client => context.clientSafe; - - List _items = []; - bool _isLoading = false; - String? _errorMessage; + dynamic get mediaItem => widget.playlist; @override - void initState() { - super.initState(); - _loadPlaylistItems(); - } + String get title => widget.playlist.title; - Future _loadPlaylistItems() async { - setState(() { - _isLoading = true; - _errorMessage = null; - }); + @override + String get emptyMessage => t.playlists.emptyPlaylist; + + @override + Future loadItems() async { + if (mounted) { + setState(() { + isLoading = true; + errorMessage = null; + }); + } try { - final clientProvider = context.plexClient; - final client = clientProvider.client; - if (client == null) { - throw Exception(t.errors.noClientAvailable); + final client = this.client; + final newItems = await client.getPlaylist(widget.playlist.ratingKey); + + if (mounted) { + setState(() { + items = newItems; + isLoading = false; + }); } - final items = await client.getPlaylist(widget.playlist.ratingKey); - - setState(() { - _items = items; - _isLoading = false; - }); - appLogger.d( - 'Loaded ${items.length} items for playlist: ${widget.playlist.title}', + 'Loaded ${newItems.length} items for playlist: ${widget.playlist.title}', ); } catch (e) { appLogger.e('Failed to load playlist items', error: e); - setState(() { - _errorMessage = 'Failed to load playlist items: ${e.toString()}'; - _isLoading = false; - }); + if (mounted) { + setState(() { + errorMessage = 'Failed to load playlist items: ${e.toString()}'; + isLoading = false; + }); + } } } Future _deletePlaylist() async { - final confirmed = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(t.playlists.deleteConfirm), - content: Text(t.playlists.deleteMessage(name: widget.playlist.title)), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: Text(t.common.cancel), - ), - TextButton( - onPressed: () => Navigator.pop(context, true), - child: Text(t.playlists.delete), - style: TextButton.styleFrom(foregroundColor: Colors.red), - ), - ], - ), + final confirmed = await showDeleteConfirmation( + context, + title: t.playlists.deleteConfirm, + message: t.playlists.deleteMessage(name: widget.playlist.title), ); if (confirmed == true && mounted) { @@ -121,7 +103,7 @@ class _PlaylistDetailScreenState extends State // Can't reorder if indices are the same if (oldIndex == newIndex) return; - final movedItem = _items[oldIndex]; + final movedItem = items[oldIndex]; // Check if item has playlistItemID (required for reordering) if (movedItem.playlistItemID == null) { @@ -141,7 +123,7 @@ class _PlaylistDetailScreenState extends State if (newIndex == 0) { afterPlaylistItemId = 0; // Move to top } else { - final afterItem = _items[newIndex - 1]; + final afterItem = items[newIndex - 1]; if (afterItem.playlistItemID == null) { appLogger.e('Cannot reorder: after item missing playlistItemID'); if (mounted) { @@ -160,8 +142,8 @@ class _PlaylistDetailScreenState extends State // Optimistically update UI setState(() { - final item = _items.removeAt(oldIndex); - _items.insert(newIndex, item); + final item = items.removeAt(oldIndex); + items.insert(newIndex, item); }); // Call API to persist the change @@ -176,8 +158,8 @@ class _PlaylistDetailScreenState extends State appLogger.e('Failed to reorder playlist item, reverting UI'); if (mounted) { setState(() { - final item = _items.removeAt(newIndex); - _items.insert(oldIndex, item); + final item = items.removeAt(newIndex); + items.insert(oldIndex, item); }); ScaffoldMessenger.of( @@ -188,7 +170,7 @@ class _PlaylistDetailScreenState extends State } Future _removeItem(int index) async { - final item = _items[index]; + final item = items[index]; // Check if item has playlistItemID (required for removal) if (item.playlistItemID == null) { @@ -207,7 +189,7 @@ class _PlaylistDetailScreenState extends State // Optimistically update UI setState(() { - _items.removeAt(index); + items.removeAt(index); }); // Call API to persist the change @@ -225,7 +207,7 @@ class _PlaylistDetailScreenState extends State // Revert on failure appLogger.e('Failed to remove playlist item, reverting UI'); setState(() { - _items.insert(index, item); + items.insert(index, item); }); ScaffoldMessenger.of( @@ -235,72 +217,16 @@ class _PlaylistDetailScreenState extends State } } - @override - void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) { - final index = _items.indexWhere((item) => item.ratingKey == ratingKey); - if (index != -1) { - _items[index] = updatedMetadata; - } - } - - @override - void refresh() { - _loadPlaylistItems(); - } - - Future _playPlaylist() async { - if (_items.isEmpty) { - if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(t.playlists.emptyPlaylist))); - } - return; - } - - final clientProvider = context.plexClient; - final client = clientProvider.client; - if (client == null) return; - - await playCollectionOrPlaylist( - context: context, - client: client, - item: widget.playlist, - shuffle: false, - ); - } - - Future _shufflePlayPlaylist() async { - if (_items.isEmpty) { - if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(t.playlists.emptyPlaylist))); - } - return; - } - - final clientProvider = context.plexClient; - final client = clientProvider.client; - if (client == null) return; - - await playCollectionOrPlaylist( - context: context, - client: client, - item: widget.playlist, - shuffle: true, - ); - } Future _playFromItem(int index) async { - if (_items.isEmpty || index < 0 || index >= _items.length) return; + if (items.isEmpty || index < 0 || index >= items.length) return; try { final clientProvider = context.plexClient; final client = clientProvider.client; if (client == null) return; - final selectedItem = _items[index]; + final selectedItem = items[index]; // Create play queue from playlist, starting at the selected item final playQueue = await client.createPlayQueue( @@ -385,18 +311,18 @@ class _PlaylistDetailScreenState extends State pinned: true, actions: [ // Play button - if (_items.isNotEmpty) + if (items.isNotEmpty) IconButton( icon: const Icon(Icons.play_arrow), tooltip: t.discover.play, - onPressed: _playPlaylist, + onPressed: playItems, ), // Shuffle button - if (_items.isNotEmpty) + if (items.isNotEmpty) IconButton( icon: const Icon(Icons.shuffle), tooltip: t.playlists.shuffle, - onPressed: _shufflePlayPlaylist, + onPressed: shufflePlayItems, ), // Delete button for non-smart playlists if (!widget.playlist.smart) @@ -408,7 +334,7 @@ class _PlaylistDetailScreenState extends State ), ], ), - if (_errorMessage != null) + if (errorMessage != null) SliverFillRemaining( child: Center( child: Column( @@ -420,21 +346,21 @@ class _PlaylistDetailScreenState extends State color: Colors.red, ), const SizedBox(height: 16), - Text(_errorMessage!), + Text(errorMessage!), const SizedBox(height: 16), ElevatedButton( - onPressed: _loadPlaylistItems, + onPressed: loadItems, child: Text(t.common.retry), ), ], ), ), ) - else if (_items.isEmpty && _isLoading) + else if (items.isEmpty && isLoading) const SliverFillRemaining( child: Center(child: CircularProgressIndicator()), ) - else if (_items.isEmpty) + else if (items.isEmpty) SliverFillRemaining( child: Center( child: Column( @@ -460,7 +386,7 @@ class _PlaylistDetailScreenState extends State padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), sliver: SliverGrid( gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: _getMaxCrossAxisExtent( + maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent( context, context.watch().libraryDensity, ), @@ -469,15 +395,15 @@ class _PlaylistDetailScreenState extends State mainAxisSpacing: 0, ), delegate: SliverChildBuilderDelegate((context, index) { - return MediaCard(item: _items[index], onRefresh: updateItem); - }, childCount: _items.length), + return MediaCard(item: items[index], onRefresh: updateItem); + }, childCount: items.length), ), ) else // Regular playlists: Use reorderable list view SliverReorderableList( itemBuilder: (context, index) { - final item = _items[index]; + final item = items[index]; return PlaylistItemCard( key: ValueKey(item.playlistItemID ?? item.ratingKey), item: item, @@ -487,75 +413,11 @@ class _PlaylistDetailScreenState extends State canReorder: !widget.playlist.smart, ); }, - itemCount: _items.length, + itemCount: items.length, onReorder: _onReorder, ), ], ), ); } - - double _getMaxCrossAxisExtent(BuildContext context, LibraryDensity density) { - final screenWidth = MediaQuery.of(context).size.width; - final padding = 16.0; - final availableWidth = screenWidth - padding; - - if (screenWidth >= 900) { - double divisor; - double maxItemWidth; - - switch (density) { - case LibraryDensity.comfortable: - divisor = 6.5; - maxItemWidth = 280; - break; - case LibraryDensity.normal: - divisor = 8.0; - maxItemWidth = 200; - break; - case LibraryDensity.compact: - divisor = 10.0; - maxItemWidth = 160; - break; - } - - return (availableWidth / divisor).clamp(120, maxItemWidth); - } else if (screenWidth >= 600) { - double divisor; - double maxItemWidth; - - switch (density) { - case LibraryDensity.comfortable: - divisor = 4.5; - maxItemWidth = 220; - break; - case LibraryDensity.normal: - divisor = 5.5; - maxItemWidth = 180; - break; - case LibraryDensity.compact: - divisor = 7.0; - maxItemWidth = 140; - break; - } - - return (availableWidth / divisor).clamp(100, maxItemWidth); - } else { - double divisor; - - switch (density) { - case LibraryDensity.comfortable: - divisor = 2.2; - break; - case LibraryDensity.normal: - divisor = 2.8; - break; - case LibraryDensity.compact: - divisor = 3.5; - break; - } - - return availableWidth / divisor; - } - } } diff --git a/lib/screens/playlists_screen.dart b/lib/screens/playlists_screen.dart index 16166ef8..26fc1fd8 100644 --- a/lib/screens/playlists_screen.dart +++ b/lib/screens/playlists_screen.dart @@ -3,9 +3,10 @@ import 'package:provider/provider.dart'; import '../client/plex_client.dart'; import '../models/plex_playlist.dart'; import '../providers/settings_provider.dart'; -import '../services/settings_service.dart'; import '../utils/provider_extensions.dart'; import '../utils/app_logger.dart'; +import '../utils/grid_size_calculator.dart'; +import '../utils/dialogs.dart'; import '../widgets/desktop_app_bar.dart'; import '../mixins/refreshable.dart'; import '../i18n/strings.g.dart'; @@ -141,7 +142,7 @@ class _PlaylistsScreenState extends State with Refreshable { padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), sliver: SliverGrid( gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: _getMaxCrossAxisExtent( + maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent( context, context.watch().libraryDensity, ), @@ -170,70 +171,6 @@ class _PlaylistsScreenState extends State with Refreshable { ), ); } - - double _getMaxCrossAxisExtent(BuildContext context, LibraryDensity density) { - final screenWidth = MediaQuery.of(context).size.width; - final padding = 16.0; - final availableWidth = screenWidth - padding; - - if (screenWidth >= 900) { - double divisor; - double maxItemWidth; - - switch (density) { - case LibraryDensity.comfortable: - divisor = 6.5; - maxItemWidth = 280; - break; - case LibraryDensity.normal: - divisor = 8.0; - maxItemWidth = 200; - break; - case LibraryDensity.compact: - divisor = 10.0; - maxItemWidth = 160; - break; - } - - return (availableWidth / divisor).clamp(120, maxItemWidth); - } else if (screenWidth >= 600) { - double divisor; - double maxItemWidth; - - switch (density) { - case LibraryDensity.comfortable: - divisor = 4.5; - maxItemWidth = 220; - break; - case LibraryDensity.normal: - divisor = 5.5; - maxItemWidth = 180; - break; - case LibraryDensity.compact: - divisor = 7.0; - maxItemWidth = 140; - break; - } - - return (availableWidth / divisor).clamp(100, maxItemWidth); - } else { - double divisor; - - switch (density) { - case LibraryDensity.comfortable: - divisor = 2.2; - break; - case LibraryDensity.normal: - divisor = 2.8; - break; - case LibraryDensity.compact: - divisor = 3.5; - break; - } - - return availableWidth / divisor; - } - } } /// Widget to display a single playlist card @@ -249,23 +186,10 @@ class _PlaylistCard extends StatelessWidget { }); Future _showDeleteDialog(BuildContext context) async { - final confirmed = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(t.playlists.deleteConfirm), - content: Text(t.playlists.deleteMessage(name: playlist.title)), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: Text(t.common.cancel), - ), - TextButton( - onPressed: () => Navigator.pop(context, true), - child: Text(t.playlists.delete), - style: TextButton.styleFrom(foregroundColor: Colors.red), - ), - ], - ), + final confirmed = await showDeleteConfirmation( + context, + title: t.playlists.deleteConfirm, + message: t.playlists.deleteMessage(name: playlist.title), ); if (confirmed == true && context.mounted) { diff --git a/lib/screens/server_selection_screen.dart b/lib/screens/server_selection_screen.dart index 8f142ca2..2740e7a7 100644 --- a/lib/screens/server_selection_screen.dart +++ b/lib/screens/server_selection_screen.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:dio/dio.dart'; import '../i18n/strings.g.dart'; import '../services/plex_auth_service.dart'; import '../services/storage_service.dart'; @@ -9,6 +10,7 @@ import '../widgets/server_list_tile.dart'; import '../widgets/desktop_app_bar.dart'; import '../utils/app_logger.dart'; import '../utils/provider_extensions.dart'; +import '../utils/error_message_utils.dart'; import 'main_screen.dart'; class ServerSelectionScreen extends StatefulWidget { @@ -74,6 +76,12 @@ class _ServerSelectionScreenState extends State { } String _getErrorMessage(dynamic error) { + if (error is DioException) { + return mapDioErrorToMessage( + error, + context: t.serverSelection.noServersFound, + ); + } if (error is ServerParsingException) { return t.serverSelection.malformedServerData( count: error.invalidServerData.length, diff --git a/lib/utils/dialogs.dart b/lib/utils/dialogs.dart new file mode 100644 index 00000000..ac1840a5 --- /dev/null +++ b/lib/utils/dialogs.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; +import '../i18n/strings.g.dart'; + +/// Utility functions for showing common dialogs + +/// Shows a delete confirmation dialog +/// Returns true if user confirmed, false if cancelled +Future showDeleteConfirmation( + BuildContext context, { + required String title, + required String message, +}) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(title), + content: Text(message), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(t.common.cancel), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: Text(t.common.delete), + ), + ], + ), + ); + + return confirmed ?? false; +} diff --git a/lib/utils/error_message_utils.dart b/lib/utils/error_message_utils.dart new file mode 100644 index 00000000..7290d374 --- /dev/null +++ b/lib/utils/error_message_utils.dart @@ -0,0 +1,29 @@ +import 'package:dio/dio.dart'; +import '../i18n/strings.g.dart'; +import 'app_logger.dart'; + +/// Shared helpers for translating network errors into user-friendly messages. +String mapDioErrorToMessage(DioException error, {required String context}) { + switch (error.type) { + case DioExceptionType.connectionTimeout: + case DioExceptionType.receiveTimeout: + return t.errors.connectionTimeout(context: context); + case DioExceptionType.connectionError: + return t.errors.connectionFailed; + default: + appLogger.e('Error loading $context', error: error); + return t.errors.failedToLoad( + context: context, + error: error.message ?? t.common.unknown, + ); + } +} + +/// Generic fallback for unexpected errors. +String mapUnexpectedErrorToMessage(dynamic error, {required String context}) { + appLogger.e('Unexpected error in $context', error: error); + return t.errors.failedToLoad( + context: context, + error: error.toString(), + ); +} diff --git a/lib/widgets/adaptive_media_grid.dart b/lib/widgets/adaptive_media_grid.dart new file mode 100644 index 00000000..cf8b37eb --- /dev/null +++ b/lib/widgets/adaptive_media_grid.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../models/plex_metadata.dart'; +import '../providers/settings_provider.dart'; +import '../services/settings_service.dart' show ViewMode; +import '../utils/grid_size_calculator.dart'; +import 'media_card.dart'; + +/// A widget that automatically switches between grid and list view +/// based on user settings, providing a consistent layout pattern +/// across all library screens +class AdaptiveMediaGrid extends StatelessWidget { + /// The list of media items to display + final List items; + + /// Callback when the list needs to be refreshed + final VoidCallback? onRefresh; + + /// Optional padding around the grid/list + final EdgeInsets padding; + + /// Child aspect ratio for grid items (width / height) + final double childAspectRatio; + + const AdaptiveMediaGrid({ + super.key, + required this.items, + this.onRefresh, + this.padding = const EdgeInsets.fromLTRB(8, 8, 8, 8), + this.childAspectRatio = 2 / 3.3, + }); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, settingsProvider, child) { + if (settingsProvider.viewMode == ViewMode.list) { + return ListView.builder( + padding: padding, + itemCount: items.length, + itemBuilder: (context, index) { + final item = items[index]; + return MediaCard( + key: Key(item.ratingKey), + item: item, + onListRefresh: onRefresh, + ); + }, + ); + } else { + return GridView.builder( + padding: padding, + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent( + context, + settingsProvider.libraryDensity, + ), + childAspectRatio: childAspectRatio, + crossAxisSpacing: 0, + mainAxisSpacing: 0, + ), + itemCount: items.length, + itemBuilder: (context, index) { + final item = items[index]; + return MediaCard( + key: Key(item.ratingKey), + item: item, + onListRefresh: onRefresh, + ); + }, + ); + } + }, + ); + } +} diff --git a/lib/widgets/content_state_builder.dart b/lib/widgets/content_state_builder.dart new file mode 100644 index 00000000..0cae5805 --- /dev/null +++ b/lib/widgets/content_state_builder.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import '../i18n/strings.g.dart'; + +/// A widget that handles loading, error, empty, and content states +/// Provides a consistent UI pattern across the app for data-driven screens +class ContentStateBuilder extends StatelessWidget { + /// Whether data is currently loading + final bool isLoading; + + /// Error message to display (null if no error) + final String? errorMessage; + + /// The list of items to display + final List items; + + /// Icon to display when the list is empty + final IconData emptyIcon; + + /// Message to display when the list is empty + final String emptyMessage; + + /// Callback when user taps retry button + final VoidCallback onRetry; + + /// Builder for the content when items are available + final Widget Function(List items) builder; + + const ContentStateBuilder({ + super.key, + required this.isLoading, + required this.errorMessage, + required this.items, + required this.emptyIcon, + required this.emptyMessage, + required this.onRetry, + required this.builder, + }); + + @override + Widget build(BuildContext context) { + // Loading state (only show loading indicator if items list is empty) + if (isLoading && items.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + + // Error state (only show error if items list is empty) + if (errorMessage != null && items.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, size: 48, color: Colors.red), + const SizedBox(height: 16), + Text( + errorMessage!, + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white70), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: onRetry, + child: Text(t.common.retry), + ), + ], + ), + ); + } + + // Empty state + if (items.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(emptyIcon, size: 64, color: Colors.grey), + const SizedBox(height: 16), + Text( + emptyMessage, + style: const TextStyle(color: Colors.white70), + ), + ], + ), + ); + } + + // Content state - delegate to builder + return builder(items); + } +} diff --git a/lib/widgets/video_controls/sheets/audio_track_sheet.dart b/lib/widgets/video_controls/sheets/audio_track_sheet.dart index 40354f17..e28043f7 100644 --- a/lib/widgets/video_controls/sheets/audio_track_sheet.dart +++ b/lib/widgets/video_controls/sheets/audio_track_sheet.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:media_kit/media_kit.dart'; import '../../../i18n/strings.g.dart'; +import 'base_video_control_sheet.dart'; /// Bottom sheet for selecting audio tracks class AudioTrackSheet extends StatelessWidget { @@ -9,27 +10,13 @@ class AudioTrackSheet extends StatelessWidget { const AudioTrackSheet({super.key, required this.player, this.onTrackChanged}); - static BoxConstraints getBottomSheetConstraints(BuildContext context) { - final size = MediaQuery.of(context).size; - final isDesktop = size.width > 600; - - return BoxConstraints( - maxWidth: isDesktop ? 700 : double.infinity, - maxHeight: isDesktop ? 400 : size.height * 0.75, - minHeight: isDesktop ? 300 : size.height * 0.5, - ); - } - static void show( BuildContext context, Player player, { Function(AudioTrack)? onTrackChanged, }) { - showModalBottomSheet( + BaseVideoControlSheet.showSheet( context: context, - backgroundColor: Colors.grey[900], - isScrollControlled: true, - constraints: getBottomSheetConstraints(context), builder: (context) => AudioTrackSheet(player: player, onTrackChanged: onTrackChanged), ); @@ -46,108 +33,73 @@ class AudioTrackSheet extends StatelessWidget { .where((track) => track.id != 'auto' && track.id != 'no') .toList(); - return SafeArea( - child: SizedBox( - height: MediaQuery.of(context).size.height * 0.75, - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - const Icon(Icons.audiotrack, color: Colors.white), - const SizedBox(width: 12), - Text( - t.videoControls.audioLabel, - style: const TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - const Spacer(), - IconButton( - icon: const Icon(Icons.close, color: Colors.white), - onPressed: () => Navigator.pop(context), - ), - ], + return BaseVideoControlSheet( + title: t.videoControls.audioLabel, + icon: Icons.audiotrack, + child: audioTracks.isEmpty + ? const Center( + child: Text( + 'No audio tracks available', + style: TextStyle(color: Colors.white70), ), - ), - const Divider(color: Colors.white24, height: 1), - if (audioTracks.isEmpty) - const Expanded( - child: Center( - child: Text( - 'No audio tracks available', - style: TextStyle(color: Colors.white70), - ), - ), - ) - else - Expanded( - child: StreamBuilder( - stream: player.stream.track, - initialData: player.state.track, - builder: (context, selectedSnapshot) { - // Use snapshot data or fall back to current state - final currentTrack = - selectedSnapshot.data ?? player.state.track; - final selectedTrack = currentTrack.audio; - final selectedId = selectedTrack.id; + ) + : StreamBuilder( + stream: player.stream.track, + initialData: player.state.track, + builder: (context, selectedSnapshot) { + // Use snapshot data or fall back to current state + final currentTrack = + selectedSnapshot.data ?? player.state.track; + final selectedTrack = currentTrack.audio; + final selectedId = selectedTrack.id; - return ListView.builder( - itemCount: audioTracks.length, - itemBuilder: (context, index) { - final audioTrack = audioTracks[index]; - final isSelected = audioTrack.id == selectedId; + return ListView.builder( + itemCount: audioTracks.length, + itemBuilder: (context, index) { + final audioTrack = audioTracks[index]; + final isSelected = audioTrack.id == selectedId; - final parts = []; - if (audioTrack.title != null && - audioTrack.title!.isNotEmpty) { - parts.add(audioTrack.title!); - } - if (audioTrack.language != null && - audioTrack.language!.isNotEmpty) { - parts.add(audioTrack.language!.toUpperCase()); - } - if (audioTrack.codec != null && - audioTrack.codec!.isNotEmpty) { - parts.add(audioTrack.codec!.toUpperCase()); - } - if (audioTrack.channelscount != null) { - parts.add('${audioTrack.channelscount}ch'); - } + final parts = []; + if (audioTrack.title != null && + audioTrack.title!.isNotEmpty) { + parts.add(audioTrack.title!); + } + if (audioTrack.language != null && + audioTrack.language!.isNotEmpty) { + parts.add(audioTrack.language!.toUpperCase()); + } + if (audioTrack.codec != null && + audioTrack.codec!.isNotEmpty) { + parts.add(audioTrack.codec!.toUpperCase()); + } + if (audioTrack.channelscount != null) { + parts.add('${audioTrack.channelscount}ch'); + } - final label = parts.isEmpty - ? 'Audio Track ${index + 1}' - : parts.join(' · '); + final label = parts.isEmpty + ? 'Audio Track ${index + 1}' + : parts.join(' · '); - return ListTile( - title: Text( - label, - style: TextStyle( - color: isSelected - ? Colors.blue - : Colors.white, - ), - ), - trailing: isSelected - ? const Icon(Icons.check, color: Colors.blue) - : null, - onTap: () { - player.setAudioTrack(audioTrack); - onTrackChanged?.call(audioTrack); - Navigator.pop(context); - }, - ); + return ListTile( + title: Text( + label, + style: TextStyle( + color: isSelected ? Colors.blue : Colors.white, + ), + ), + trailing: isSelected + ? const Icon(Icons.check, color: Colors.blue) + : null, + onTap: () { + player.setAudioTrack(audioTrack); + onTrackChanged?.call(audioTrack); + Navigator.pop(context); }, ); }, - ), - ), - ], - ), - ), + ); + }, + ), ); }, ); diff --git a/lib/widgets/video_controls/sheets/base_video_control_sheet.dart b/lib/widgets/video_controls/sheets/base_video_control_sheet.dart new file mode 100644 index 00000000..d02a875a --- /dev/null +++ b/lib/widgets/video_controls/sheets/base_video_control_sheet.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; + +/// Base class for video control bottom sheets providing common UI structure +class BaseVideoControlSheet extends StatelessWidget { + final String title; + final IconData icon; + final Widget child; + final Color? iconColor; + + const BaseVideoControlSheet({ + super.key, + required this.title, + required this.icon, + required this.child, + this.iconColor, + }); + + /// Get consistent bottom sheet constraints across all video control sheets + static BoxConstraints getBottomSheetConstraints(BuildContext context) { + final size = MediaQuery.of(context).size; + final isDesktop = size.width > 600; + + return BoxConstraints( + maxWidth: isDesktop ? 700 : double.infinity, + maxHeight: isDesktop ? 400 : size.height * 0.75, + minHeight: isDesktop ? 300 : size.height * 0.5, + ); + } + + /// Helper method to show a modal bottom sheet with consistent styling + static Future showSheet({ + required BuildContext context, + required WidgetBuilder builder, + }) { + return showModalBottomSheet( + context: context, + backgroundColor: Colors.grey[900], + isScrollControlled: true, + constraints: getBottomSheetConstraints(context), + builder: builder, + ); + } + + @override + Widget build(BuildContext context) { + return SafeArea( + child: SizedBox( + height: MediaQuery.of(context).size.height * 0.75, + child: Column( + children: [ + _buildHeader(context), + const Divider(color: Colors.white24, height: 1), + Expanded(child: child), + ], + ), + ), + ); + } + + Widget _buildHeader(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Icon(icon, color: iconColor ?? Colors.white), + const SizedBox(width: 12), + Text( + title, + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.close, color: Colors.white), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/video_controls/sheets/chapter_sheet.dart b/lib/widgets/video_controls/sheets/chapter_sheet.dart index f77ea04a..a89a9e53 100644 --- a/lib/widgets/video_controls/sheets/chapter_sheet.dart +++ b/lib/widgets/video_controls/sheets/chapter_sheet.dart @@ -3,6 +3,7 @@ import 'package:media_kit/media_kit.dart'; import 'package:provider/provider.dart'; import '../../../models/plex_media_info.dart'; import '../../../providers/plex_client_provider.dart'; +import 'base_video_control_sheet.dart'; /// Bottom sheet for selecting chapters class ChapterSheet extends StatelessWidget { @@ -17,28 +18,14 @@ class ChapterSheet extends StatelessWidget { required this.chaptersLoaded, }); - static BoxConstraints getBottomSheetConstraints(BuildContext context) { - final size = MediaQuery.of(context).size; - final isDesktop = size.width > 600; - - return BoxConstraints( - maxWidth: isDesktop ? 700 : double.infinity, - maxHeight: isDesktop ? 400 : size.height * 0.75, - minHeight: isDesktop ? 300 : size.height * 0.5, - ); - } - static void show( BuildContext context, Player player, List chapters, bool chaptersLoaded, ) { - showModalBottomSheet( + BaseVideoControlSheet.showSheet( context: context, - backgroundColor: Colors.grey[900], - isScrollControlled: true, - constraints: getBottomSheetConstraints(context), builder: (context) => ChapterSheet( player: player, chapters: chapters, @@ -73,8 +60,7 @@ class ChapterSheet extends StatelessWidget { for (int i = 0; i < chapters.length; i++) { final chapter = chapters[i]; final startMs = chapter.startTimeOffset ?? 0; - final endMs = - chapter.endTimeOffset ?? + final endMs = chapter.endTimeOffset ?? (i < chapters.length - 1 ? chapters[i + 1].startTimeOffset ?? 0 : double.maxFinite.toInt()); @@ -85,148 +71,114 @@ class ChapterSheet extends StatelessWidget { } } - return SafeArea( - child: SizedBox( - height: MediaQuery.of(context).size.height * 0.75, - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - const Icon(Icons.video_library, color: Colors.white), - const SizedBox(width: 12), - const Text( - 'Chapters', - style: TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - const Spacer(), - IconButton( - icon: const Icon(Icons.close, color: Colors.white), - onPressed: () => Navigator.pop(context), - ), - ], + Widget content; + if (!chaptersLoaded) { + content = const Center(child: CircularProgressIndicator()); + } else if (chapters.isEmpty) { + content = const Center( + child: Text( + 'No chapters available', + style: TextStyle(color: Colors.white70), + ), + ); + } else { + content = ListView.builder( + itemCount: chapters.length, + itemBuilder: (context, index) { + final chapter = chapters[index]; + final isCurrentChapter = currentChapterIndex == index; + + return ListTile( + leading: chapter.thumb != null + ? Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Consumer( + builder: (context, clientProvider, child) { + final client = clientProvider.client; + if (client == null) { + return const Icon( + Icons.image, + color: Colors.white54, + size: 34, + ); + } + return Image.network( + client.getThumbnailUrl( + chapter.thumb, + ), + width: 60, + height: 34, + fit: BoxFit.cover, + errorBuilder: ( + context, + error, + stackTrace, + ) => + const Icon( + Icons.image, + color: Colors.white54, + size: 34, + ), + ); + }, + ), + ), + if (isCurrentChapter) + Positioned.fill( + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular( + 4, + ), + border: Border.all( + color: Colors.blue, + width: 2, + ), + ), + ), + ), + ], + ) + : null, + title: Text( + chapter.label, + style: TextStyle( + color: isCurrentChapter ? Colors.blue : Colors.white, + fontWeight: isCurrentChapter + ? FontWeight.bold + : FontWeight.normal, ), ), - const Divider(color: Colors.white24, height: 1), - if (!chaptersLoaded) - const Expanded( - child: Center(child: CircularProgressIndicator()), - ) - else if (chapters.isEmpty) - const Expanded( - child: Center( - child: Text( - 'No chapters available', - style: TextStyle(color: Colors.white70), - ), - ), - ) - else - Expanded( - child: ListView.builder( - itemCount: chapters.length, - itemBuilder: (context, index) { - final chapter = chapters[index]; - final isCurrentChapter = currentChapterIndex == index; - - return ListTile( - leading: chapter.thumb != null - ? Stack( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(4), - child: Consumer( - builder: - (context, clientProvider, child) { - final client = - clientProvider.client; - if (client == null) { - return const Icon( - Icons.image, - color: Colors.white54, - size: 34, - ); - } - return Image.network( - client.getThumbnailUrl( - chapter.thumb, - ), - width: 60, - height: 34, - fit: BoxFit.cover, - errorBuilder: - ( - context, - error, - stackTrace, - ) => const Icon( - Icons.image, - color: Colors.white54, - size: 34, - ), - ); - }, - ), - ), - if (isCurrentChapter) - Positioned.fill( - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular( - 4, - ), - border: Border.all( - color: Colors.blue, - width: 2, - ), - ), - ), - ), - ], - ) - : null, - title: Text( - chapter.label, - style: TextStyle( - color: isCurrentChapter - ? Colors.blue - : Colors.white, - fontWeight: isCurrentChapter - ? FontWeight.bold - : FontWeight.normal, - ), - ), - subtitle: Text( - _formatDuration(chapter.startTime), - style: TextStyle( - color: isCurrentChapter - ? Colors.blue.withValues(alpha: 0.7) - : Colors.white70, - fontSize: 12, - ), - ), - trailing: isCurrentChapter - ? const Icon( - Icons.play_circle_filled, - color: Colors.blue, - ) - : null, - onTap: () { - player.seek(chapter.startTime); - Navigator.pop(context); - }, - ); - }, - ), + subtitle: Text( + _formatDuration(chapter.startTime), + style: TextStyle( + color: isCurrentChapter + ? Colors.blue.withValues(alpha: 0.7) + : Colors.white70, + fontSize: 12, ), - ], - ), - ), + ), + trailing: isCurrentChapter + ? const Icon( + Icons.play_circle_filled, + color: Colors.blue, + ) + : null, + onTap: () { + player.seek(chapter.startTime); + Navigator.pop(context); + }, + ); + }, + ); + } + + return BaseVideoControlSheet( + title: 'Chapters', + icon: Icons.video_library, + child: content, ); }, ); diff --git a/lib/widgets/video_controls/sheets/playback_speed_sheet.dart b/lib/widgets/video_controls/sheets/playback_speed_sheet.dart index ea96e225..ad11ea0a 100644 --- a/lib/widgets/video_controls/sheets/playback_speed_sheet.dart +++ b/lib/widgets/video_controls/sheets/playback_speed_sheet.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:media_kit/media_kit.dart'; +import 'base_video_control_sheet.dart'; /// Bottom sheet for selecting playback speed class PlaybackSpeedSheet extends StatelessWidget { @@ -7,23 +8,9 @@ class PlaybackSpeedSheet extends StatelessWidget { const PlaybackSpeedSheet({super.key, required this.player}); - static BoxConstraints getBottomSheetConstraints(BuildContext context) { - final size = MediaQuery.of(context).size; - final isDesktop = size.width > 600; - - return BoxConstraints( - maxWidth: isDesktop ? 700 : double.infinity, - maxHeight: isDesktop ? 400 : size.height * 0.75, - minHeight: isDesktop ? 300 : size.height * 0.5, - ); - } - static void show(BuildContext context, Player player) { - showModalBottomSheet( + BaseVideoControlSheet.showSheet( context: context, - backgroundColor: Colors.grey[900], - isScrollControlled: true, - constraints: getBottomSheetConstraints(context), builder: (context) => PlaybackSpeedSheet(player: player), ); } @@ -39,66 +26,35 @@ class PlaybackSpeedSheet extends StatelessWidget { // Define available playback speeds final speeds = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0, 2.5, 3.0]; - return SafeArea( - child: SizedBox( - height: MediaQuery.of(context).size.height * 0.75, - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - const Icon(Icons.speed, color: Colors.white), - const SizedBox(width: 12), - const Text( - 'Playback Speed', - style: TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - const Spacer(), - IconButton( - icon: const Icon(Icons.close, color: Colors.white), - onPressed: () => Navigator.pop(context), - ), - ], + return BaseVideoControlSheet( + title: 'Playback Speed', + icon: Icons.speed, + child: ListView.builder( + itemCount: speeds.length, + itemBuilder: (context, index) { + final speed = speeds[index]; + final isSelected = (currentRate - speed).abs() < 0.01; + + // Format speed label + final label = + speed == 1.0 ? 'Normal' : '${speed.toStringAsFixed(2)}x'; + + return ListTile( + title: Text( + label, + style: TextStyle( + color: isSelected ? Colors.blue : Colors.white, ), ), - const Divider(color: Colors.white24, height: 1), - Expanded( - child: ListView.builder( - itemCount: speeds.length, - itemBuilder: (context, index) { - final speed = speeds[index]; - final isSelected = (currentRate - speed).abs() < 0.01; - - // Format speed label - final label = speed == 1.0 - ? 'Normal' - : '${speed.toStringAsFixed(2)}x'; - - return ListTile( - title: Text( - label, - style: TextStyle( - color: isSelected ? Colors.blue : Colors.white, - ), - ), - trailing: isSelected - ? const Icon(Icons.check, color: Colors.blue) - : null, - onTap: () { - player.setRate(speed); - Navigator.pop(context); - }, - ); - }, - ), - ), - ], - ), + trailing: isSelected + ? const Icon(Icons.check, color: Colors.blue) + : null, + onTap: () { + player.setRate(speed); + Navigator.pop(context); + }, + ); + }, ), ); }, diff --git a/lib/widgets/video_controls/sheets/sleep_timer_sheet.dart b/lib/widgets/video_controls/sheets/sleep_timer_sheet.dart index b2786ba3..14c768ad 100644 --- a/lib/widgets/video_controls/sheets/sleep_timer_sheet.dart +++ b/lib/widgets/video_controls/sheets/sleep_timer_sheet.dart @@ -2,7 +2,8 @@ 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'; +import 'base_video_control_sheet.dart'; +import '../widgets/sleep_timer_content.dart'; /// Bottom sheet for sleep timer configuration class SleepTimerSheet extends StatelessWidget { @@ -15,47 +16,19 @@ class SleepTimerSheet extends StatelessWidget { required this.defaultDuration, }); - static BoxConstraints getBottomSheetConstraints(BuildContext context) { - final size = MediaQuery.of(context).size; - final isDesktop = size.width > 600; - - return BoxConstraints( - maxWidth: isDesktop ? 700 : double.infinity, - maxHeight: isDesktop ? 400 : size.height * 0.75, - minHeight: isDesktop ? 300 : size.height * 0.5, - ); - } - static void show(BuildContext context, Player player) async { final settingsService = await SettingsService.getInstance(); final defaultDuration = settingsService.getSleepTimerDuration(); if (!context.mounted) return; - showModalBottomSheet( + BaseVideoControlSheet.showSheet( context: context, - backgroundColor: Colors.grey[900], - isScrollControlled: true, - constraints: getBottomSheetConstraints(context), builder: (context) => SleepTimerSheet(player: player, defaultDuration: defaultDuration), ); } - String _formatSleepTimerDuration(Duration duration) { - final hours = duration.inHours; - final minutes = duration.inMinutes.remainder(60); - final seconds = duration.inSeconds.remainder(60); - - if (hours > 0) { - return '${hours}h ${minutes}m ${seconds}s'; - } else if (minutes > 0) { - return '${minutes}m ${seconds}s'; - } else { - return '${seconds}s'; - } - } - @override Widget build(BuildContext context) { final sleepTimer = SleepTimerService(); @@ -63,169 +36,15 @@ class SleepTimerSheet extends StatelessWidget { return ListenableBuilder( listenable: sleepTimer, builder: (context, _) { - final durations = [5, 10, 15, 30, 45, 60, 90, 120]; - // Add default duration if not in list - if (!durations.contains(defaultDuration)) { - durations.add(defaultDuration); - durations.sort(); - } - final remainingTime = sleepTimer.remainingTime; - - return SafeArea( - child: SizedBox( - height: MediaQuery.of(context).size.height * 0.75, - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - Icon( - sleepTimer.isActive - ? Icons.bedtime - : Icons.bedtime_outlined, - color: sleepTimer.isActive - ? Colors.amber - : Colors.white, - ), - const SizedBox(width: 12), - const Text( - 'Sleep Timer', - style: TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - const Spacer(), - IconButton( - icon: const Icon(Icons.close, color: Colors.white), - onPressed: () => Navigator.pop(context), - ), - ], - ), - ), - const Divider(color: Colors.white24, height: 1), - - // Show current timer status if active - if (sleepTimer.isActive && remainingTime != null) ...[ - Container( - padding: const EdgeInsets.all(16), - color: Colors.amber.withValues(alpha: 0.1), - child: Column( - children: [ - const Text( - 'Timer Active', - style: TextStyle( - color: Colors.amber, - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Text( - 'Playback will pause in ${_formatSleepTimerDuration(remainingTime)}', - style: const TextStyle( - color: Colors.white70, - fontSize: 14, - ), - ), - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - OutlinedButton.icon( - icon: const Icon(Icons.add), - label: Text( - t.videoControls.addTime( - amount: "15", - unit: " min", - ), - ), - style: OutlinedButton.styleFrom( - foregroundColor: Colors.white, - side: const BorderSide(color: Colors.white54), - ), - onPressed: () { - sleepTimer.extendTimer( - const Duration(minutes: 15), - ); - }, - ), - const SizedBox(width: 12), - FilledButton.icon( - icon: const Icon(Icons.cancel), - label: Text(t.common.cancel), - style: FilledButton.styleFrom( - backgroundColor: Colors.red, - ), - onPressed: () { - sleepTimer.cancelTimer(); - Navigator.pop(context); - }, - ), - ], - ), - ], - ), - ), - const Divider(color: Colors.white24, height: 1), - ], - - // Duration selection list - Expanded( - child: ListView.builder( - itemCount: durations.length, - itemBuilder: (context, index) { - final minutes = durations[index]; - final label = minutes < 60 - ? '$minutes minutes' - : '${(minutes / 60).toStringAsFixed(minutes % 60 == 0 ? 0 : 1)} ${minutes == 60 ? 'hour' : 'hours'}'; - - return ListTile( - leading: const Icon(Icons.timer, color: Colors.white70), - title: Text( - label, - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.normal, - ), - ), - onTap: () { - sleepTimer.startTimer(Duration(minutes: minutes), () { - // Pause playback when timer completes - player.pause(); - - // Show a snackbar notification - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Sleep timer completed - playback paused', - ), - duration: Duration(seconds: 3), - ), - ); - } - }); - Navigator.pop(context); - - // Show confirmation snackbar - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - t.messages.sleepTimerSet(label: label), - ), - duration: const Duration(seconds: 2), - ), - ); - }, - ); - }, - ), - ), - ], - ), + return BaseVideoControlSheet( + title: 'Sleep Timer', + icon: sleepTimer.isActive ? Icons.bedtime : Icons.bedtime_outlined, + iconColor: sleepTimer.isActive ? Colors.amber : null, + child: SleepTimerContent( + player: player, + sleepTimer: sleepTimer, + defaultDuration: defaultDuration, + onCancel: () => Navigator.pop(context), ), ); }, diff --git a/lib/widgets/video_controls/sheets/subtitle_track_sheet.dart b/lib/widgets/video_controls/sheets/subtitle_track_sheet.dart index 4f375627..faf01b97 100644 --- a/lib/widgets/video_controls/sheets/subtitle_track_sheet.dart +++ b/lib/widgets/video_controls/sheets/subtitle_track_sheet.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:media_kit/media_kit.dart'; import '../../../i18n/strings.g.dart'; +import 'base_video_control_sheet.dart'; /// Bottom sheet for selecting subtitle tracks class SubtitleTrackSheet extends StatelessWidget { @@ -13,27 +14,13 @@ class SubtitleTrackSheet extends StatelessWidget { this.onTrackChanged, }); - static BoxConstraints getBottomSheetConstraints(BuildContext context) { - final size = MediaQuery.of(context).size; - final isDesktop = size.width > 600; - - return BoxConstraints( - maxWidth: isDesktop ? 700 : double.infinity, - maxHeight: isDesktop ? 400 : size.height * 0.75, - minHeight: isDesktop ? 300 : size.height * 0.5, - ); - } - static void show( BuildContext context, Player player, { Function(SubtitleTrack)? onTrackChanged, }) { - showModalBottomSheet( + BaseVideoControlSheet.showSheet( context: context, - backgroundColor: Colors.grey[900], - isScrollControlled: true, - constraints: getBottomSheetConstraints(context), builder: (context) => SubtitleTrackSheet(player: player, onTrackChanged: onTrackChanged), ); @@ -50,146 +37,108 @@ class SubtitleTrackSheet extends StatelessWidget { .where((track) => track.id != 'auto' && track.id != 'no') .toList(); - return SafeArea( - child: SizedBox( - height: MediaQuery.of(context).size.height * 0.75, - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - const Icon(Icons.subtitles, color: Colors.white), - const SizedBox(width: 12), - Text( - t.videoControls.subtitlesLabel, - style: const TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - const Spacer(), - IconButton( - icon: const Icon(Icons.close, color: Colors.white), - onPressed: () => Navigator.pop(context), - ), - ], + return BaseVideoControlSheet( + title: t.videoControls.subtitlesLabel, + icon: Icons.subtitles, + child: subtitles.isEmpty + ? const Center( + child: Text( + 'No subtitles available', + style: TextStyle(color: Colors.white70), ), - ), - const Divider(color: Colors.white24, height: 1), - if (subtitles.isEmpty) - const Expanded( - child: Center( - child: Text( - 'No subtitles available', - style: TextStyle(color: Colors.white70), - ), - ), - ) - else - Expanded( - child: StreamBuilder( - stream: player.stream.track, - initialData: player.state.track, - builder: (context, selectedSnapshot) { - // Use snapshot data or fall back to current state - final currentTrack = - selectedSnapshot.data ?? player.state.track; - final selectedTrack = currentTrack.subtitle; - final selectedId = selectedTrack.id; - final isOffSelected = selectedId == 'no'; + ) + : StreamBuilder( + stream: player.stream.track, + initialData: player.state.track, + builder: (context, selectedSnapshot) { + // Use snapshot data or fall back to current state + final currentTrack = + selectedSnapshot.data ?? player.state.track; + final selectedTrack = currentTrack.subtitle; + final selectedId = selectedTrack.id; + final isOffSelected = selectedId == 'no'; - return ListView.builder( - itemCount: - subtitles.length + 1, // +1 for "Off" option - itemBuilder: (context, index) { - // First item is "Off" - if (index == 0) { - return ListTile( - title: Text( - 'Off', - style: TextStyle( - color: isOffSelected - ? Colors.blue - : Colors.white, - ), - ), - trailing: isOffSelected - ? const Icon( - Icons.check, - color: Colors.blue, - ) - : null, - onTap: () { - player.setSubtitleTrack(SubtitleTrack.no()); - onTrackChanged?.call(SubtitleTrack.no()); - Navigator.pop(context); - }, - ); - } - - // Subsequent items are subtitle tracks - final subtitle = subtitles[index - 1]; - final isSelected = subtitle.id == selectedId; - - // Build label with available info - final parts = []; - if (subtitle.title != null && - subtitle.title!.isNotEmpty) { - parts.add(subtitle.title!); - } - if (subtitle.language != null && - subtitle.language!.isNotEmpty) { - parts.add(subtitle.language!.toUpperCase()); - } - if (subtitle.codec != null && - subtitle.codec!.isNotEmpty) { - // Format codec names nicely - String codecName = subtitle.codec!.toUpperCase(); - if (codecName == 'SUBRIP') { - codecName = 'SRT'; - } else if (codecName == 'DVD_SUBTITLE') { - codecName = 'DVD'; - } else if (codecName == 'ASS' || - codecName == 'SSA') { - codecName = codecName; // Keep as-is - } else if (codecName == 'WEBVTT') { - codecName = 'VTT'; - } - parts.add(codecName); - } - - final label = parts.isEmpty - ? 'Track $index' - : parts.join(' · '); - - return ListTile( - title: Text( - label, - style: TextStyle( - color: isSelected - ? Colors.blue - : Colors.white, - ), + return ListView.builder( + itemCount: subtitles.length + 1, // +1 for "Off" option + itemBuilder: (context, index) { + // First item is "Off" + if (index == 0) { + return ListTile( + title: Text( + 'Off', + style: TextStyle( + color: + isOffSelected ? Colors.blue : Colors.white, ), - trailing: isSelected - ? const Icon(Icons.check, color: Colors.blue) - : null, - onTap: () { - player.setSubtitleTrack(subtitle); - onTrackChanged?.call(subtitle); - Navigator.pop(context); - }, - ); + ), + trailing: isOffSelected + ? const Icon( + Icons.check, + color: Colors.blue, + ) + : null, + onTap: () { + player.setSubtitleTrack(SubtitleTrack.no()); + onTrackChanged?.call(SubtitleTrack.no()); + Navigator.pop(context); + }, + ); + } + + // Subsequent items are subtitle tracks + final subtitle = subtitles[index - 1]; + final isSelected = subtitle.id == selectedId; + + // Build label with available info + final parts = []; + if (subtitle.title != null && + subtitle.title!.isNotEmpty) { + parts.add(subtitle.title!); + } + if (subtitle.language != null && + subtitle.language!.isNotEmpty) { + parts.add(subtitle.language!.toUpperCase()); + } + if (subtitle.codec != null && + subtitle.codec!.isNotEmpty) { + // Format codec names nicely + String codecName = subtitle.codec!.toUpperCase(); + if (codecName == 'SUBRIP') { + codecName = 'SRT'; + } else if (codecName == 'DVD_SUBTITLE') { + codecName = 'DVD'; + } else if (codecName == 'ASS' || + codecName == 'SSA') { + codecName = codecName; // Keep as-is + } else if (codecName == 'WEBVTT') { + codecName = 'VTT'; + } + parts.add(codecName); + } + + final label = + parts.isEmpty ? 'Track $index' : parts.join(' · '); + + return ListTile( + title: Text( + label, + style: TextStyle( + color: isSelected ? Colors.blue : Colors.white, + ), + ), + trailing: isSelected + ? const Icon(Icons.check, color: Colors.blue) + : null, + onTap: () { + player.setSubtitleTrack(subtitle); + onTrackChanged?.call(subtitle); + Navigator.pop(context); }, ); }, - ), - ), - ], - ), - ), + ); + }, + ), ); }, ); diff --git a/lib/widgets/video_controls/sheets/version_sheet.dart b/lib/widgets/video_controls/sheets/version_sheet.dart index eafd4dfe..32540cce 100644 --- a/lib/widgets/video_controls/sheets/version_sheet.dart +++ b/lib/widgets/video_controls/sheets/version_sheet.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import '../../../models/plex_media_version.dart'; +import 'base_video_control_sheet.dart'; /// Bottom sheet for selecting video version class VersionSheet extends StatelessWidget { @@ -14,28 +15,14 @@ class VersionSheet extends StatelessWidget { required this.onVersionSelected, }); - static BoxConstraints getBottomSheetConstraints(BuildContext context) { - final size = MediaQuery.of(context).size; - final isDesktop = size.width > 600; - - return BoxConstraints( - maxWidth: isDesktop ? 700 : double.infinity, - maxHeight: isDesktop ? 400 : size.height * 0.75, - minHeight: isDesktop ? 300 : size.height * 0.5, - ); - } - static void show( BuildContext context, List availableVersions, int selectedMediaIndex, Function(int) onVersionSelected, ) { - showModalBottomSheet( + BaseVideoControlSheet.showSheet( context: context, - backgroundColor: Colors.grey[900], - isScrollControlled: true, - constraints: getBottomSheetConstraints(context), builder: (context) => VersionSheet( availableVersions: availableVersions, selectedMediaIndex: selectedMediaIndex, @@ -46,61 +33,31 @@ class VersionSheet extends StatelessWidget { @override Widget build(BuildContext context) { - return SafeArea( - child: SizedBox( - height: MediaQuery.of(context).size.height * 0.75, - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - const Icon(Icons.video_file, color: Colors.white), - const SizedBox(width: 12), - const Text( - 'Video Version', - style: TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - const Spacer(), - IconButton( - icon: const Icon(Icons.close, color: Colors.white), - onPressed: () => Navigator.pop(context), - ), - ], - ), - ), - const Divider(color: Colors.white24, height: 1), - Expanded( - child: ListView.builder( - itemCount: availableVersions.length, - itemBuilder: (context, index) { - final version = availableVersions[index]; - final isSelected = index == selectedMediaIndex; + return BaseVideoControlSheet( + title: 'Video Version', + icon: Icons.video_file, + child: ListView.builder( + itemCount: availableVersions.length, + itemBuilder: (context, index) { + final version = availableVersions[index]; + final isSelected = index == selectedMediaIndex; - return ListTile( - title: Text( - version.displayLabel, - style: TextStyle( - color: isSelected ? Colors.blue : Colors.white, - ), - ), - trailing: isSelected - ? const Icon(Icons.check, color: Colors.blue) - : null, - onTap: () { - Navigator.pop(context); - onVersionSelected(index); - }, - ); - }, + return ListTile( + title: Text( + version.displayLabel, + style: TextStyle( + color: isSelected ? Colors.blue : Colors.white, ), ), - ], - ), + trailing: isSelected + ? const Icon(Icons.check, color: Colors.blue) + : null, + onTap: () { + Navigator.pop(context); + onVersionSelected(index); + }, + ); + }, ), ); } diff --git a/lib/widgets/video_controls/sheets/video_settings_sheet.dart b/lib/widgets/video_controls/sheets/video_settings_sheet.dart index fb78e421..28ee1211 100644 --- a/lib/widgets/video_controls/sheets/video_settings_sheet.dart +++ b/lib/widgets/video_controls/sheets/video_settings_sheet.dart @@ -4,10 +4,65 @@ import '../../../services/settings_service.dart'; import '../../../services/sleep_timer_service.dart'; import '../../../utils/platform_detector.dart'; import '../widgets/sync_offset_control.dart'; +import '../widgets/sleep_timer_content.dart'; import '../../../i18n/strings.g.dart'; enum _SettingsView { menu, speed, sleep, audioSync, subtitleSync, audioDevice } +/// Reusable menu item widget for settings sheet +class _SettingsMenuItem extends StatelessWidget { + final IconData icon; + final String title; + final String valueText; + final VoidCallback onTap; + final bool isHighlighted; + final bool allowValueOverflow; + + const _SettingsMenuItem({ + required this.icon, + required this.title, + required this.valueText, + required this.onTap, + this.isHighlighted = false, + this.allowValueOverflow = false, + }); + + @override + Widget build(BuildContext context) { + final valueWidget = Text( + valueText, + style: TextStyle( + color: isHighlighted ? Colors.amber : Colors.white70, + fontSize: 14, + ), + overflow: allowValueOverflow ? TextOverflow.ellipsis : null, + ); + + return ListTile( + leading: Icon( + icon, + color: isHighlighted ? Colors.amber : Colors.white70, + ), + title: Text( + title, + style: const TextStyle(color: Colors.white), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (allowValueOverflow) + Flexible(child: valueWidget) + else + valueWidget, + const SizedBox(width: 8), + const Icon(Icons.chevron_right, color: Colors.white70), + ], + ), + onTap: onTap, + ); + } +} + /// Unified settings sheet for playback adjustments with in-sheet navigation class VideoSettingsSheet extends StatefulWidget { final Player player; @@ -139,19 +194,6 @@ class _VideoSettingsSheetState extends State { } } - String _formatSleepTimerDuration(Duration duration) { - final hours = duration.inHours; - final minutes = duration.inMinutes.remainder(60); - final seconds = duration.inSeconds.remainder(60); - - if (hours > 0) { - return '${hours}h ${minutes}m ${seconds}s'; - } else if (minutes > 0) { - return '${minutes}m ${seconds}s'; - } else { - return '${seconds}s'; - } - } Widget _buildHeader() { final sleepTimer = SleepTimerService(); @@ -204,23 +246,10 @@ class _VideoSettingsSheetState extends State { initialData: widget.player.state.rate, builder: (context, snapshot) { final currentRate = snapshot.data ?? 1.0; - return ListTile( - leading: const Icon(Icons.speed, color: Colors.white70), - title: const Text( - 'Playback Speed', - style: TextStyle(color: Colors.white), - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - _formatSpeed(currentRate), - style: const TextStyle(color: Colors.white70, fontSize: 14), - ), - const SizedBox(width: 8), - const Icon(Icons.chevron_right, color: Colors.white70), - ], - ), + return _SettingsMenuItem( + icon: Icons.speed, + title: 'Playback Speed', + valueText: _formatSpeed(currentRate), onTap: () => _navigateTo(_SettingsView.speed), ); }, @@ -231,87 +260,31 @@ class _VideoSettingsSheetState extends State { listenable: sleepTimer, builder: (context, _) { final isActive = sleepTimer.isActive; - return ListTile( - leading: Icon( - isActive ? Icons.bedtime : Icons.bedtime_outlined, - color: isActive ? Colors.amber : Colors.white70, - ), - title: const Text( - 'Sleep Timer', - style: TextStyle(color: Colors.white), - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - _formatSleepTimer(sleepTimer), - style: TextStyle( - color: isActive ? Colors.amber : Colors.white70, - fontSize: 14, - ), - ), - const SizedBox(width: 8), - const Icon(Icons.chevron_right, color: Colors.white70), - ], - ), + return _SettingsMenuItem( + icon: isActive ? Icons.bedtime : Icons.bedtime_outlined, + title: 'Sleep Timer', + valueText: _formatSleepTimer(sleepTimer), + isHighlighted: isActive, onTap: () => _navigateTo(_SettingsView.sleep), ); }, ), // Audio Sync - ListTile( - leading: Icon( - Icons.sync, - color: _audioSyncOffset != 0 ? Colors.amber : Colors.white70, - ), - title: const Text( - 'Audio Sync', - style: TextStyle(color: Colors.white), - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - _formatAudioSync(_audioSyncOffset), - style: TextStyle( - color: _audioSyncOffset != 0 ? Colors.amber : Colors.white70, - fontSize: 14, - ), - ), - const SizedBox(width: 8), - const Icon(Icons.chevron_right, color: Colors.white70), - ], - ), + _SettingsMenuItem( + icon: Icons.sync, + title: 'Audio Sync', + valueText: _formatAudioSync(_audioSyncOffset), + isHighlighted: _audioSyncOffset != 0, onTap: () => _navigateTo(_SettingsView.audioSync), ), // Subtitle Sync - ListTile( - leading: Icon( - Icons.subtitles, - color: _subtitleSyncOffset != 0 ? Colors.amber : Colors.white70, - ), - title: const Text( - 'Subtitle Sync', - style: TextStyle(color: Colors.white), - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - _formatAudioSync(_subtitleSyncOffset), - style: TextStyle( - color: _subtitleSyncOffset != 0 - ? Colors.amber - : Colors.white70, - fontSize: 14, - ), - ), - const SizedBox(width: 8), - const Icon(Icons.chevron_right, color: Colors.white70), - ], - ), + _SettingsMenuItem( + icon: Icons.subtitles, + title: 'Subtitle Sync', + valueText: _formatAudioSync(_subtitleSyncOffset), + isHighlighted: _subtitleSyncOffset != 0, onTap: () => _navigateTo(_SettingsView.subtitleSync), ), @@ -327,29 +300,11 @@ class _VideoSettingsSheetState extends State { ? currentDevice.name : currentDevice.description; - return ListTile( - leading: const Icon(Icons.speaker, color: Colors.white70), - title: const Text( - 'Audio Output', - style: TextStyle(color: Colors.white), - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: Text( - deviceLabel, - style: const TextStyle( - color: Colors.white70, - fontSize: 14, - ), - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: 8), - const Icon(Icons.chevron_right, color: Colors.white70), - ], - ), + return _SettingsMenuItem( + icon: Icons.speaker, + title: 'Audio Output', + valueText: deviceLabel, + allowValueOverflow: true, onTap: () => _navigateTo(_SettingsView.audioDevice), ); }, @@ -399,119 +354,10 @@ class _VideoSettingsSheetState extends State { Widget _buildSleepView() { final sleepTimer = SleepTimerService(); - return ListenableBuilder( - listenable: sleepTimer, - builder: (context, _) { - final durations = [5, 10, 15, 30, 45, 60, 90, 120]; - final remainingTime = sleepTimer.remainingTime; - - return Column( - children: [ - // Active timer status - if (sleepTimer.isActive && remainingTime != null) ...[ - Container( - padding: const EdgeInsets.all(16), - color: Colors.amber.withValues(alpha: 0.1), - child: Column( - children: [ - const Text( - 'Timer Active', - style: TextStyle( - color: Colors.amber, - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Text( - 'Playback will pause in ${_formatSleepTimerDuration(remainingTime)}', - style: const TextStyle( - color: Colors.white70, - fontSize: 14, - ), - ), - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - OutlinedButton.icon( - icon: const Icon(Icons.add), - label: Text( - t.videoControls.addTime(amount: "15", unit: " min"), - ), - style: OutlinedButton.styleFrom( - foregroundColor: Colors.white, - side: const BorderSide(color: Colors.white54), - ), - onPressed: () { - sleepTimer.extendTimer(const Duration(minutes: 15)); - }, - ), - const SizedBox(width: 12), - FilledButton.icon( - icon: const Icon(Icons.cancel), - label: Text(t.common.cancel), - style: FilledButton.styleFrom( - backgroundColor: Colors.red, - ), - onPressed: () { - sleepTimer.cancelTimer(); - Navigator.pop(context); // Close after cancel - }, - ), - ], - ), - ], - ), - ), - const Divider(color: Colors.white24, height: 1), - ], - - // Duration list - Expanded( - child: ListView.builder( - itemCount: durations.length, - itemBuilder: (context, index) { - final minutes = durations[index]; - final label = minutes < 60 - ? '$minutes minutes' - : '${(minutes / 60).toStringAsFixed(minutes % 60 == 0 ? 0 : 1)} ${minutes == 60 ? 'hour' : 'hours'}'; - - return ListTile( - leading: const Icon(Icons.timer, color: Colors.white70), - title: Text( - label, - style: const TextStyle(color: Colors.white), - ), - onTap: () { - sleepTimer.startTimer(Duration(minutes: minutes), () { - widget.player.pause(); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Sleep timer completed - playback paused', - ), - duration: Duration(seconds: 3), - ), - ); - } - }); - Navigator.pop(context); // Close after selection - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(t.messages.sleepTimerSet(label: label)), - duration: const Duration(seconds: 2), - ), - ); - }, - ); - }, - ), - ), - ], - ); - }, + return SleepTimerContent( + player: widget.player, + sleepTimer: sleepTimer, + onCancel: () => Navigator.pop(context), ); } diff --git a/lib/widgets/video_controls/widgets/sleep_timer_active_status.dart b/lib/widgets/video_controls/widgets/sleep_timer_active_status.dart new file mode 100644 index 00000000..b218aa9a --- /dev/null +++ b/lib/widgets/video_controls/widgets/sleep_timer_active_status.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import '../../../services/sleep_timer_service.dart'; +import '../../../i18n/strings.g.dart'; + +/// Widget displaying active sleep timer status with extend/cancel actions +class SleepTimerActiveStatus extends StatelessWidget { + final SleepTimerService sleepTimer; + final Duration remainingTime; + final VoidCallback? onCancel; + + const SleepTimerActiveStatus({ + super.key, + required this.sleepTimer, + required this.remainingTime, + this.onCancel, + }); + + String _formatDuration(Duration duration) { + final hours = duration.inHours; + final minutes = duration.inMinutes.remainder(60); + final seconds = duration.inSeconds.remainder(60); + + if (hours > 0) { + return '${hours}h ${minutes}m ${seconds}s'; + } else if (minutes > 0) { + return '${minutes}m ${seconds}s'; + } else { + return '${seconds}s'; + } + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + color: Colors.amber.withValues(alpha: 0.1), + child: Column( + children: [ + const Text( + 'Timer Active', + style: TextStyle( + color: Colors.amber, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + 'Playback will pause in ${_formatDuration(remainingTime)}', + style: const TextStyle( + color: Colors.white70, + fontSize: 14, + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + OutlinedButton.icon( + icon: const Icon(Icons.add), + label: Text( + t.videoControls.addTime( + amount: "15", + unit: " min", + ), + ), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + side: const BorderSide(color: Colors.white54), + ), + onPressed: () { + sleepTimer.extendTimer( + const Duration(minutes: 15), + ); + }, + ), + const SizedBox(width: 12), + FilledButton.icon( + icon: const Icon(Icons.cancel), + label: Text(t.common.cancel), + style: FilledButton.styleFrom( + backgroundColor: Colors.red, + ), + onPressed: () { + sleepTimer.cancelTimer(); + onCancel?.call(); + }, + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/widgets/video_controls/widgets/sleep_timer_content.dart b/lib/widgets/video_controls/widgets/sleep_timer_content.dart new file mode 100644 index 00000000..c23611d0 --- /dev/null +++ b/lib/widgets/video_controls/widgets/sleep_timer_content.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:media_kit/media_kit.dart'; +import '../../../services/sleep_timer_service.dart'; +import 'sleep_timer_active_status.dart'; +import 'sleep_timer_duration_list.dart'; + +/// Shared UI for sleep timer selection and active status. +class SleepTimerContent extends StatelessWidget { + final Player player; + final SleepTimerService sleepTimer; + final int? defaultDuration; + final VoidCallback? onCancel; + + const SleepTimerContent({ + super.key, + required this.player, + required this.sleepTimer, + this.defaultDuration, + this.onCancel, + }); + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: sleepTimer, + builder: (context, _) { + final remainingTime = sleepTimer.remainingTime; + + return Column( + children: [ + if (sleepTimer.isActive && remainingTime != null) ...[ + SleepTimerActiveStatus( + sleepTimer: sleepTimer, + remainingTime: remainingTime, + onCancel: onCancel, + ), + const Divider(color: Colors.white24, height: 1), + ], + Expanded( + child: SleepTimerDurationList( + player: player, + sleepTimer: sleepTimer, + defaultDuration: defaultDuration, + ), + ), + ], + ); + }, + ); + } +} diff --git a/lib/widgets/video_controls/widgets/sleep_timer_duration_list.dart b/lib/widgets/video_controls/widgets/sleep_timer_duration_list.dart new file mode 100644 index 00000000..f1a674bf --- /dev/null +++ b/lib/widgets/video_controls/widgets/sleep_timer_duration_list.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:media_kit/media_kit.dart'; +import '../../../services/sleep_timer_service.dart'; +import '../../../i18n/strings.g.dart'; + +/// Widget displaying list of sleep timer durations for selection +class SleepTimerDurationList extends StatelessWidget { + final Player player; + final SleepTimerService sleepTimer; + final int? defaultDuration; + + const SleepTimerDurationList({ + super.key, + required this.player, + required this.sleepTimer, + this.defaultDuration, + }); + + String _formatLabel(int minutes) { + if (minutes < 60) { + return '$minutes minutes'; + } + final hours = minutes / 60; + final isWholeHour = minutes % 60 == 0; + return '${hours.toStringAsFixed(isWholeHour ? 0 : 1)} ${minutes == 60 ? 'hour' : 'hours'}'; + } + + @override + Widget build(BuildContext context) { + final durations = [5, 10, 15, 30, 45, 60, 90, 120]; + // Add default duration if provided and not already in list + if (defaultDuration != null && !durations.contains(defaultDuration)) { + durations.add(defaultDuration!); + durations.sort(); + } + + return ListView.builder( + itemCount: durations.length, + itemBuilder: (context, index) { + final minutes = durations[index]; + final label = _formatLabel(minutes); + + return ListTile( + leading: const Icon(Icons.timer, color: Colors.white70), + title: Text( + label, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.normal, + ), + ), + onTap: () { + sleepTimer.startTimer(Duration(minutes: minutes), () { + // Pause playback when timer completes + player.pause(); + + // Show a snackbar notification + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Sleep timer completed - playback paused', + ), + duration: Duration(seconds: 3), + ), + ); + } + }); + Navigator.pop(context); + + // Show confirmation snackbar + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + t.messages.sleepTimerSet(label: label), + ), + duration: const Duration(seconds: 2), + ), + ); + }, + ); + }, + ); + } +} From a6927f0edfb4282dc8e041e0126ab5d336c41102 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 16 Nov 2025 23:44:53 +0100 Subject: [PATCH 26/29] refactor: consolidate duration format --- lib/client/plex_client.dart | 66 +++++----- lib/i18n/strings.g.dart | 52 +++++++- lib/i18n/strings.i18n.json | 6 +- lib/i18n/strings_de.i18n.json | 6 +- lib/i18n/strings_it.i18n.json | 6 +- lib/i18n/strings_nl.i18n.json | 6 +- lib/i18n/strings_sv.i18n.json | 6 +- lib/i18n/strings_zh.i18n.json | 6 +- lib/models/plex_playlist.dart | 11 -- lib/providers/playback_state_provider.dart | 21 ++-- .../base_media_list_detail_screen.dart | 9 +- lib/screens/collection_detail_screen.dart | 58 ++++----- lib/screens/libraries_screen.dart | 30 ++--- .../library_tabs/library_browse_tab.dart | 12 +- .../library_tabs/library_collections_tab.dart | 14 +-- .../library_tabs/library_playlists_tab.dart | 1 - .../library_tabs/library_recommended_tab.dart | 10 +- lib/screens/media_detail_screen.dart | 15 +-- lib/screens/playlist_detail_screen.dart | 5 +- lib/screens/season_detail_screen.dart | 18 +-- lib/screens/video_player_screen.dart | 4 +- lib/utils/duration_formatter.dart | 83 ++++++++++++ lib/widgets/media_card.dart | 118 +++++++++--------- lib/widgets/playlist_item_card.dart | 15 +-- .../video_controls/sheets/chapter_sheet.dart | 15 +-- .../sheets/sleep_timer_sheet.dart | 3 +- .../video_controls/video_controls.dart | 20 +-- .../widgets/sleep_timer_active_status.dart | 41 ++---- .../widgets/sleep_timer_duration_list.dart | 27 ++-- pubspec.lock | 8 ++ pubspec.yaml | 1 + 31 files changed, 378 insertions(+), 315 deletions(-) create mode 100644 lib/utils/duration_formatter.dart diff --git a/lib/client/plex_client.dart b/lib/client/plex_client.dart index 3bc42b2e..1aa617c8 100644 --- a/lib/client/plex_client.dart +++ b/lib/client/plex_client.dart @@ -1498,9 +1498,7 @@ class PlexClient { try { final response = await _dio.get( '/library/sections/$sectionId/collections', - queryParameters: { - 'includeGuids': 1, - }, + queryParameters: {'includeGuids': 1}, ); final allItems = _extractMetadataList(response); @@ -1518,7 +1516,9 @@ class PlexClient { /// Returns the list of metadata items in the collection Future> getCollectionItems(String collectionId) async { try { - final response = await _dio.get('/library/collections/$collectionId/children'); + final response = await _dio.get( + '/library/collections/$collectionId/children', + ); return _extractMetadataList(response); } catch (e) { appLogger.e('Failed to get collection items: $e'); @@ -1530,7 +1530,9 @@ class PlexClient { /// Deletes a library collection from the server Future deleteCollection(String sectionId, String collectionId) async { try { - appLogger.d('Deleting collection: sectionId=$sectionId, collectionId=$collectionId'); + appLogger.d( + 'Deleting collection: sectionId=$sectionId, collectionId=$collectionId', + ); final response = await _dio.delete('/library/collections/$collectionId'); appLogger.d('Delete collection response: ${response.statusCode}'); return true; @@ -1550,7 +1552,9 @@ class PlexClient { int? type, }) async { try { - appLogger.d('Creating collection: sectionId=$sectionId, title=$title, type=$type'); + appLogger.d( + 'Creating collection: sectionId=$sectionId, title=$title, type=$type', + ); final response = await _dio.post( '/library/collections', queryParameters: { @@ -1591,9 +1595,7 @@ class PlexClient { appLogger.d('Adding items to collection: collectionId=$collectionId'); final response = await _dio.put( '/library/collections/$collectionId/items', - queryParameters: { - 'uri': uri, - }, + queryParameters: {'uri': uri}, ); appLogger.d('Add to collection response: ${response.statusCode}'); return true; @@ -1610,7 +1612,9 @@ class PlexClient { required String itemId, }) async { try { - appLogger.d('Removing item from collection: collectionId=$collectionId, itemId=$itemId'); + appLogger.d( + 'Removing item from collection: collectionId=$collectionId, itemId=$itemId', + ); final response = await _dio.delete( '/library/collections/$collectionId/items/$itemId', ); @@ -1739,15 +1743,17 @@ class PlexClient { // If full parsing fails, use minimal safe parsing appLogger.d('Using minimal parsing for metadata item: $e'); try { - items.add(PlexMetadata( - ratingKey: json['key'] ?? json['ratingKey'] ?? '', - key: json['key'] ?? '', - type: json['type'] ?? 'folder', - title: json['title'] ?? 'Untitled', - thumb: json['thumb'], - art: json['art'], - year: json['year'], - )); + items.add( + PlexMetadata( + ratingKey: json['key'] ?? json['ratingKey'] ?? '', + key: json['key'] ?? '', + type: json['type'] ?? 'folder', + title: json['title'] ?? 'Untitled', + thumb: json['thumb'], + art: json['art'], + year: json['year'], + ), + ); } catch (e2) { appLogger.e('Failed to parse metadata item: $e2'); } @@ -1764,14 +1770,16 @@ class PlexClient { } catch (e) { // If that fails, use minimal folder representation try { - items.add(PlexMetadata( - ratingKey: json['key'] ?? json['ratingKey'] ?? '', - key: json['key'] ?? '', - type: json['type'] ?? 'folder', - title: json['title'] ?? 'Untitled', - thumb: json['thumb'], - art: json['art'], - )); + items.add( + PlexMetadata( + ratingKey: json['key'] ?? json['ratingKey'] ?? '', + key: json['key'] ?? '', + type: json['type'] ?? 'folder', + title: json['title'] ?? 'Untitled', + thumb: json['thumb'], + art: json['art'], + ), + ); } catch (e2) { appLogger.e('Failed to parse directory item: $e2'); } @@ -1789,9 +1797,7 @@ class PlexClient { try { final response = await _dio.get( '/library/sections/$sectionId/folder', - queryParameters: { - 'includeCollections': 0, - }, + queryParameters: {'includeCollections': 0}, ); return _extractMetadataAndDirectories(response); } catch (e) { diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 67236e1d..4d9f68fc 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 6 -/// Strings: 2424 (404 per locale) +/// Strings: 2448 (408 per locale) /// -/// Built on 2025-11-16 at 19:45 UTC +/// Built on 2025-11-16 at 22:35 UTC // coverage:ignore-file // ignore_for_file: type=lint @@ -477,6 +477,10 @@ class _StringsVideoControlsEn { String get stretch => 'Stretch'; String get lockRotation => 'Lock rotation'; String get unlockRotation => 'Unlock rotation'; + String get sleepTimer => 'Sleep Timer'; + String get timerActive => 'Timer Active'; + String playbackWillPauseIn({required Object duration}) => 'Playback will pause in ${duration}'; + String get sleepTimerCompleted => 'Sleep timer completed - playback paused'; } // Path: userStatus @@ -1217,6 +1221,10 @@ class _StringsVideoControlsDe implements _StringsVideoControlsEn { @override String get stretch => 'Strecken'; @override String get lockRotation => 'Rotation sperren'; @override String get unlockRotation => 'Rotation entsperren'; + @override String get sleepTimer => 'Schlaf-Timer'; + @override String get timerActive => 'Timer aktiv'; + @override String playbackWillPauseIn({required Object duration}) => 'Wiedergabe wird pausiert in ${duration}'; + @override String get sleepTimerCompleted => 'Schlaf-Timer abgelaufen - Wiedergabe pausiert'; } // Path: userStatus @@ -1957,6 +1965,10 @@ class _StringsVideoControlsIt implements _StringsVideoControlsEn { @override String get stretch => 'Allunga'; @override String get lockRotation => 'Blocca rotazione'; @override String get unlockRotation => 'Sblocca rotazione'; + @override String get sleepTimer => 'Timer di spegnimento'; + @override String get timerActive => 'Timer attivo'; + @override String playbackWillPauseIn({required Object duration}) => 'La riproduzione si interromperà tra ${duration}'; + @override String get sleepTimerCompleted => 'Timer di spegnimento completato - riproduzione in pausa'; } // Path: userStatus @@ -2697,6 +2709,10 @@ class _StringsVideoControlsNl implements _StringsVideoControlsEn { @override String get stretch => 'Uitrekken'; @override String get lockRotation => 'Vergrendel rotatie'; @override String get unlockRotation => 'Ontgrendel rotatie'; + @override String get sleepTimer => 'Slaaptimer'; + @override String get timerActive => 'Timer actief'; + @override String playbackWillPauseIn({required Object duration}) => 'Afspelen wordt gepauzeerd over ${duration}'; + @override String get sleepTimerCompleted => 'Slaaptimer voltooid - afspelen gepauzeerd'; } // Path: userStatus @@ -3437,6 +3453,10 @@ class _StringsVideoControlsSv implements _StringsVideoControlsEn { @override String get stretch => 'Sträck'; @override String get lockRotation => 'Lås rotation'; @override String get unlockRotation => 'Lås upp rotation'; + @override String get sleepTimer => 'Sovtimer'; + @override String get timerActive => 'Timer aktiv'; + @override String playbackWillPauseIn({required Object duration}) => 'Uppspelningen pausas om ${duration}'; + @override String get sleepTimerCompleted => 'Sovtimer slutförd - uppspelning pausad'; } // Path: userStatus @@ -4177,6 +4197,10 @@ class _StringsVideoControlsZh implements _StringsVideoControlsEn { @override String get stretch => '拉伸'; @override String get lockRotation => '锁定旋转'; @override String get unlockRotation => '解锁旋转'; + @override String get sleepTimer => '睡眠定时器'; + @override String get timerActive => '定时器已激活'; + @override String playbackWillPauseIn({required Object duration}) => '播放将在 ${duration} 后暂停'; + @override String get sleepTimerCompleted => '睡眠定时器已完成 - 播放已暂停'; } // Path: userStatus @@ -4753,6 +4777,10 @@ extension on Translations { case 'videoControls.stretch': return 'Stretch'; case 'videoControls.lockRotation': return 'Lock rotation'; case 'videoControls.unlockRotation': return 'Unlock rotation'; + case 'videoControls.sleepTimer': return 'Sleep Timer'; + case 'videoControls.timerActive': return 'Timer Active'; + case 'videoControls.playbackWillPauseIn': return ({required Object duration}) => 'Playback will pause in ${duration}'; + case 'videoControls.sleepTimerCompleted': return 'Sleep timer completed - playback paused'; case 'userStatus.admin': return 'Admin'; case 'userStatus.restricted': return 'Restricted'; case 'userStatus.protected': return 'Protected'; @@ -5165,6 +5193,10 @@ extension on _StringsDe { case 'videoControls.stretch': return 'Strecken'; case 'videoControls.lockRotation': return 'Rotation sperren'; case 'videoControls.unlockRotation': return 'Rotation entsperren'; + case 'videoControls.sleepTimer': return 'Schlaf-Timer'; + case 'videoControls.timerActive': return 'Timer aktiv'; + case 'videoControls.playbackWillPauseIn': return ({required Object duration}) => 'Wiedergabe wird pausiert in ${duration}'; + case 'videoControls.sleepTimerCompleted': return 'Schlaf-Timer abgelaufen - Wiedergabe pausiert'; case 'userStatus.admin': return 'Eigentümer'; case 'userStatus.restricted': return 'Eingeschränkt'; case 'userStatus.protected': return 'Geschützt'; @@ -5577,6 +5609,10 @@ extension on _StringsIt { case 'videoControls.stretch': return 'Allunga'; case 'videoControls.lockRotation': return 'Blocca rotazione'; case 'videoControls.unlockRotation': return 'Sblocca rotazione'; + case 'videoControls.sleepTimer': return 'Timer di spegnimento'; + case 'videoControls.timerActive': return 'Timer attivo'; + case 'videoControls.playbackWillPauseIn': return ({required Object duration}) => 'La riproduzione si interromperà tra ${duration}'; + case 'videoControls.sleepTimerCompleted': return 'Timer di spegnimento completato - riproduzione in pausa'; case 'userStatus.admin': return 'Admin'; case 'userStatus.restricted': return 'Limitato'; case 'userStatus.protected': return 'Protetto'; @@ -5989,6 +6025,10 @@ extension on _StringsNl { case 'videoControls.stretch': return 'Uitrekken'; case 'videoControls.lockRotation': return 'Vergrendel rotatie'; case 'videoControls.unlockRotation': return 'Ontgrendel rotatie'; + case 'videoControls.sleepTimer': return 'Slaaptimer'; + case 'videoControls.timerActive': return 'Timer actief'; + case 'videoControls.playbackWillPauseIn': return ({required Object duration}) => 'Afspelen wordt gepauzeerd over ${duration}'; + case 'videoControls.sleepTimerCompleted': return 'Slaaptimer voltooid - afspelen gepauzeerd'; case 'userStatus.admin': return 'Beheerder'; case 'userStatus.restricted': return 'Beperkt'; case 'userStatus.protected': return 'Beschermd'; @@ -6401,6 +6441,10 @@ extension on _StringsSv { case 'videoControls.stretch': return 'Sträck'; case 'videoControls.lockRotation': return 'Lås rotation'; case 'videoControls.unlockRotation': return 'Lås upp rotation'; + case 'videoControls.sleepTimer': return 'Sovtimer'; + case 'videoControls.timerActive': return 'Timer aktiv'; + case 'videoControls.playbackWillPauseIn': return ({required Object duration}) => 'Uppspelningen pausas om ${duration}'; + case 'videoControls.sleepTimerCompleted': return 'Sovtimer slutförd - uppspelning pausad'; case 'userStatus.admin': return 'Admin'; case 'userStatus.restricted': return 'Begränsad'; case 'userStatus.protected': return 'Skyddad'; @@ -6813,6 +6857,10 @@ extension on _StringsZh { case 'videoControls.stretch': return '拉伸'; case 'videoControls.lockRotation': return '锁定旋转'; case 'videoControls.unlockRotation': return '解锁旋转'; + case 'videoControls.sleepTimer': return '睡眠定时器'; + case 'videoControls.timerActive': return '定时器已激活'; + case 'videoControls.playbackWillPauseIn': return ({required Object duration}) => '播放将在 ${duration} 后暂停'; + case 'videoControls.sleepTimerCompleted': return '睡眠定时器已完成 - 播放已暂停'; case 'userStatus.admin': return '管理员'; case 'userStatus.restricted': return '受限'; case 'userStatus.protected': return '受保护'; diff --git a/lib/i18n/strings.i18n.json b/lib/i18n/strings.i18n.json index f2539445..e749ab23 100644 --- a/lib/i18n/strings.i18n.json +++ b/lib/i18n/strings.i18n.json @@ -202,7 +202,11 @@ "fillScreen": "Fill screen", "stretch": "Stretch", "lockRotation": "Lock rotation", - "unlockRotation": "Unlock rotation" + "unlockRotation": "Unlock rotation", + "sleepTimer": "Sleep Timer", + "timerActive": "Timer Active", + "playbackWillPauseIn": "Playback will pause in ${duration}", + "sleepTimerCompleted": "Sleep timer completed - playback paused" }, "userStatus": { "admin": "Admin", diff --git a/lib/i18n/strings_de.i18n.json b/lib/i18n/strings_de.i18n.json index 2cc35a73..6a3078d8 100644 --- a/lib/i18n/strings_de.i18n.json +++ b/lib/i18n/strings_de.i18n.json @@ -202,7 +202,11 @@ "fillScreen": "Bild füllen", "stretch": "Strecken", "lockRotation": "Rotation sperren", - "unlockRotation": "Rotation entsperren" + "unlockRotation": "Rotation entsperren", + "sleepTimer": "Schlaf-Timer", + "timerActive": "Timer aktiv", + "playbackWillPauseIn": "Wiedergabe wird pausiert in ${duration}", + "sleepTimerCompleted": "Schlaf-Timer abgelaufen - Wiedergabe pausiert" }, "userStatus": { "admin": "Eigentümer", diff --git a/lib/i18n/strings_it.i18n.json b/lib/i18n/strings_it.i18n.json index 43033fe8..76ca3fef 100644 --- a/lib/i18n/strings_it.i18n.json +++ b/lib/i18n/strings_it.i18n.json @@ -202,7 +202,11 @@ "fillScreen": "Riempi schermo", "stretch": "Allunga", "lockRotation": "Blocca rotazione", - "unlockRotation": "Sblocca rotazione" + "unlockRotation": "Sblocca rotazione", + "sleepTimer": "Timer di spegnimento", + "timerActive": "Timer attivo", + "playbackWillPauseIn": "La riproduzione si interromperà tra ${duration}", + "sleepTimerCompleted": "Timer di spegnimento completato - riproduzione in pausa" }, "userStatus": { "admin": "Admin", diff --git a/lib/i18n/strings_nl.i18n.json b/lib/i18n/strings_nl.i18n.json index 0b7fb138..63551766 100644 --- a/lib/i18n/strings_nl.i18n.json +++ b/lib/i18n/strings_nl.i18n.json @@ -202,7 +202,11 @@ "fillScreen": "Vul scherm", "stretch": "Uitrekken", "lockRotation": "Vergrendel rotatie", - "unlockRotation": "Ontgrendel rotatie" + "unlockRotation": "Ontgrendel rotatie", + "sleepTimer": "Slaaptimer", + "timerActive": "Timer actief", + "playbackWillPauseIn": "Afspelen wordt gepauzeerd over ${duration}", + "sleepTimerCompleted": "Slaaptimer voltooid - afspelen gepauzeerd" }, "userStatus": { "admin": "Beheerder", diff --git a/lib/i18n/strings_sv.i18n.json b/lib/i18n/strings_sv.i18n.json index 2340e6ea..00d7d5e8 100644 --- a/lib/i18n/strings_sv.i18n.json +++ b/lib/i18n/strings_sv.i18n.json @@ -202,7 +202,11 @@ "fillScreen": "Fyll skärm", "stretch": "Sträck", "lockRotation": "Lås rotation", - "unlockRotation": "Lås upp rotation" + "unlockRotation": "Lås upp rotation", + "sleepTimer": "Sovtimer", + "timerActive": "Timer aktiv", + "playbackWillPauseIn": "Uppspelningen pausas om ${duration}", + "sleepTimerCompleted": "Sovtimer slutförd - uppspelning pausad" }, "userStatus": { "admin": "Admin", diff --git a/lib/i18n/strings_zh.i18n.json b/lib/i18n/strings_zh.i18n.json index f19a0a78..4db098e4 100644 --- a/lib/i18n/strings_zh.i18n.json +++ b/lib/i18n/strings_zh.i18n.json @@ -202,7 +202,11 @@ "fillScreen": "填充屏幕", "stretch": "拉伸", "lockRotation": "锁定旋转", - "unlockRotation": "解锁旋转" + "unlockRotation": "解锁旋转", + "sleepTimer": "睡眠定时器", + "timerActive": "定时器已激活", + "playbackWillPauseIn": "播放将在 ${duration} 后暂停", + "sleepTimerCompleted": "睡眠定时器已完成 - 播放已暂停" }, "userStatus": { "admin": "管理员", diff --git a/lib/models/plex_playlist.dart b/lib/models/plex_playlist.dart index d47944f7..7799c3ff 100644 --- a/lib/models/plex_playlist.dart +++ b/lib/models/plex_playlist.dart @@ -45,17 +45,6 @@ class PlexPlaylist { /// Helper to get display image (composite or thumb) String? get displayImage => composite ?? thumb; - /// Helper to get formatted duration - String? get formattedDuration { - if (duration == null) return null; - final hours = duration! ~/ 3600000; - final minutes = (duration! % 3600000) ~/ 60000; - if (hours > 0) { - return '${hours}h ${minutes}m'; - } - return '${minutes}m'; - } - /// Helper to determine if playlist is editable bool get isEditable => !smart; diff --git a/lib/providers/playback_state_provider.dart b/lib/providers/playback_state_provider.dart index 3b4976df..fb6acca9 100644 --- a/lib/providers/playback_state_provider.dart +++ b/lib/providers/playback_state_provider.dart @@ -68,7 +68,8 @@ class PlaybackStateProvider with ChangeNotifier { /// Update the current play queue item when playing a new item void setCurrentItem(PlexMetadata metadata) { - if (_playbackMode == PlaybackMode.playQueue && metadata.playQueueItemID != null) { + if (_playbackMode == PlaybackMode.playQueue && + metadata.playQueueItemID != null) { _currentPlayQueueItemID = metadata.playQueueItemID; notifyListeners(); } @@ -82,9 +83,10 @@ class PlaybackStateProvider with ChangeNotifier { ) async { _playQueueId = playQueue.playQueueID; // Use size or items length as fallback if totalCount is null - _playQueueTotalCount = playQueue.playQueueTotalCount ?? - playQueue.size ?? - (playQueue.items?.length ?? 0); + _playQueueTotalCount = + playQueue.playQueueTotalCount ?? + playQueue.size ?? + (playQueue.items?.length ?? 0); _playQueueShuffled = playQueue.playQueueShuffled; _currentPlayQueueItemID = playQueue.playQueueSelectedItemID; _loadedItems = playQueue.items ?? []; @@ -140,9 +142,10 @@ class PlaybackStateProvider with ChangeNotifier { if (response != null && response.items != null) { _loadedItems = response.items!; // Use size or items length as fallback if totalCount is null - _playQueueTotalCount = response.playQueueTotalCount ?? - response.size ?? - response.items!.length; + _playQueueTotalCount = + response.playQueueTotalCount ?? + response.size ?? + response.items!.length; _playQueueShuffled = response.playQueueShuffled; notifyListeners(); return true; @@ -198,7 +201,9 @@ class PlaybackStateProvider with ChangeNotifier { // Loop back to beginning - load first item if (_client != null && _playQueueId != null) { final response = await _client!.getPlayQueue(_playQueueId!); - if (response != null && response.items != null && response.items!.isNotEmpty) { + if (response != null && + response.items != null && + response.items!.isNotEmpty) { _loadedItems = response.items!; final firstItem = _loadedItems.first; // Don't update _currentPlayQueueItemID here - let setCurrentItem do it when playback starts diff --git a/lib/screens/base_media_list_detail_screen.dart b/lib/screens/base_media_list_detail_screen.dart index c0a4d63f..57d693c7 100644 --- a/lib/screens/base_media_list_detail_screen.dart +++ b/lib/screens/base_media_list_detail_screen.dart @@ -9,7 +9,8 @@ import '../mixins/item_updatable.dart'; /// Abstract base class for screens displaying media lists (collections/playlists) /// Provides common state management and playback functionality abstract class BaseMediaListDetailScreen - extends State with Refreshable, ItemUpdatable { + extends State + with Refreshable, ItemUpdatable { // State properties - concrete implementations to avoid duplication List _items = []; bool _isLoading = false; @@ -55,9 +56,9 @@ abstract class BaseMediaListDetailScreen Future _playWithShuffle(bool shuffle) async { if (items.isEmpty) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(emptyMessage)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(emptyMessage))); } return; } diff --git a/lib/screens/collection_detail_screen.dart b/lib/screens/collection_detail_screen.dart index 376fd69d..5cd38b3d 100644 --- a/lib/screens/collection_detail_screen.dart +++ b/lib/screens/collection_detail_screen.dart @@ -43,7 +43,9 @@ class _CollectionDetailScreenState try { final client = this.client; - final newItems = await client.getCollectionItems(widget.collection.ratingKey); + final newItems = await client.getCollectionItems( + widget.collection.ratingKey, + ); if (mounted) { setState(() { @@ -108,14 +110,17 @@ class _CollectionDetailScreenState if (mounted) { if (success) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(t.collections.deleted)), - ); - Navigator.pop(context, true); // Return true to indicate refresh needed + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.collections.deleted))); + Navigator.pop( + context, + true, + ); // Return true to indicate refresh needed } else { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(t.collections.deleteFailed)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(t.collections.deleteFailed))); } } } catch (e) { @@ -132,7 +137,6 @@ class _CollectionDetailScreenState } } - @override Widget build(BuildContext context) { return Scaffold( @@ -193,9 +197,7 @@ class _CollectionDetailScreenState ) else if (items.isEmpty) SliverFillRemaining( - child: Center( - child: Text(t.collections.noItems), - ), + child: Center(child: Text(t.collections.noItems)), ) else SliverPadding( @@ -204,27 +206,25 @@ class _CollectionDetailScreenState builder: (context, settingsProvider, child) { return SliverGrid( gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent( - context, - settingsProvider.libraryDensity, - ), + maxCrossAxisExtent: + GridSizeCalculator.getMaxCrossAxisExtent( + context, + settingsProvider.libraryDensity, + ), childAspectRatio: 2 / 3.3, crossAxisSpacing: 0, mainAxisSpacing: 0, ), - delegate: SliverChildBuilderDelegate( - (context, index) { - final item = items[index]; - return MediaCard( - key: Key(item.ratingKey), - item: item, - onRefresh: updateItem, - collectionId: widget.collection.ratingKey, - onListRefresh: loadItems, - ); - }, - childCount: items.length, - ), + delegate: SliverChildBuilderDelegate((context, index) { + final item = items[index]; + return MediaCard( + key: Key(item.ratingKey), + item: item, + onRefresh: updateItem, + collectionId: widget.collection.ratingKey, + onListRefresh: loadItems, + ); + }, childCount: items.length), ); }, ), diff --git a/lib/screens/libraries_screen.dart b/lib/screens/libraries_screen.dart index 19c32668..513d3c17 100644 --- a/lib/screens/libraries_screen.dart +++ b/lib/screens/libraries_screen.dart @@ -167,8 +167,9 @@ class _LibrariesScreenState extends State } if (libraryKeyToLoad != null && mounted) { - final savedFilters = - storage.getLibraryFilters(sectionId: libraryKeyToLoad); + final savedFilters = storage.getLibraryFilters( + sectionId: libraryKeyToLoad, + ); if (savedFilters.isNotEmpty) { _selectedFilters = Map.from(savedFilters); } @@ -280,10 +281,7 @@ class _LibrariesScreenState extends State // Clear filters in storage when changing library if (isChangingLibrary) { - await storage.saveLibraryFilters( - {}, - sectionId: libraryKey, - ); + await storage.saveLibraryFilters({}, sectionId: libraryKey); } // Cancel any existing requests @@ -863,25 +861,13 @@ class _LibrariesScreenState extends State scrollDirection: Axis.horizontal, child: Row( children: [ - _buildTabChip( - t.libraries.tabs.recommended, - 0, - ), + _buildTabChip(t.libraries.tabs.recommended, 0), const SizedBox(width: 8), - _buildTabChip( - t.libraries.tabs.browse, - 1, - ), + _buildTabChip(t.libraries.tabs.browse, 1), const SizedBox(width: 8), - _buildTabChip( - t.libraries.tabs.collections, - 2, - ), + _buildTabChip(t.libraries.tabs.collections, 2), const SizedBox(width: 8), - _buildTabChip( - t.libraries.tabs.playlists, - 3, - ), + _buildTabChip(t.libraries.tabs.playlists, 3), ], ), ), diff --git a/lib/screens/library_tabs/library_browse_tab.dart b/lib/screens/library_tabs/library_browse_tab.dart index 2fd87712..29dca645 100644 --- a/lib/screens/library_tabs/library_browse_tab.dart +++ b/lib/screens/library_tabs/library_browse_tab.dart @@ -302,10 +302,7 @@ class _LibraryBrowseTabState extends State if (error is DioException) { return mapDioErrorToMessage(error, context: t.libraries.content); } - return mapUnexpectedErrorToMessage( - error, - context: t.libraries.content, - ); + return mapUnexpectedErrorToMessage(error, context: t.libraries.content); } void _showGroupingBottomSheet() { @@ -451,7 +448,7 @@ class _LibraryBrowseTabState extends State ), const SizedBox(width: 8), // Filters chip - if (_filters.isNotEmpty) + if (_filters.isNotEmpty && _selectedGrouping != 'folders') _buildFilterChip( icon: Icons.filter_alt, label: _selectedFilters.isEmpty @@ -461,9 +458,10 @@ class _LibraryBrowseTabState extends State ), onPressed: _showFiltersBottomSheet, ), - if (_filters.isNotEmpty) const SizedBox(width: 8), + if (_filters.isNotEmpty && _selectedGrouping != 'folders') + const SizedBox(width: 8), // Sort chip - if (_sortOptions.isNotEmpty) + if (_sortOptions.isNotEmpty && _selectedGrouping != 'folders') _buildFilterChip( icon: Icons.sort, label: _selectedSort?.title ?? t.libraries.sort, diff --git a/lib/screens/library_tabs/library_collections_tab.dart b/lib/screens/library_tabs/library_collections_tab.dart index f5e0f6a4..681b8231 100644 --- a/lib/screens/library_tabs/library_collections_tab.dart +++ b/lib/screens/library_tabs/library_collections_tab.dart @@ -50,7 +50,9 @@ class _LibraryCollectionsTabState extends State _loadCollections(); // Listen for refresh notifications - _refreshSubscription = LibraryRefreshNotifier().collectionsStream.listen((_) { + _refreshSubscription = LibraryRefreshNotifier().collectionsStream.listen(( + _, + ) { if (mounted) { _loadCollections(); } @@ -84,7 +86,9 @@ class _LibraryCollectionsTabState extends State throw Exception(t.errors.noClientAvailable); } - final collections = await client.getLibraryCollections(widget.library.key); + final collections = await client.getLibraryCollections( + widget.library.key, + ); if (!mounted) return; @@ -106,7 +110,6 @@ class _LibraryCollectionsTabState extends State } } - @override Widget build(BuildContext context) { super.build(context); // Required for AutomaticKeepAliveClientMixin @@ -120,10 +123,7 @@ class _LibraryCollectionsTabState extends State onRetry: _loadCollections, builder: (items) => RefreshIndicator( onRefresh: _loadCollections, - child: AdaptiveMediaGrid( - items: items, - onRefresh: _loadCollections, - ), + child: AdaptiveMediaGrid(items: items, onRefresh: _loadCollections), ), ); } diff --git a/lib/screens/library_tabs/library_playlists_tab.dart b/lib/screens/library_tabs/library_playlists_tab.dart index 8ef5156b..84f6b1be 100644 --- a/lib/screens/library_tabs/library_playlists_tab.dart +++ b/lib/screens/library_tabs/library_playlists_tab.dart @@ -113,7 +113,6 @@ class _LibraryPlaylistsTabState extends State } } - @override Widget build(BuildContext context) { super.build(context); // Required for AutomaticKeepAliveClientMixin diff --git a/lib/screens/library_tabs/library_recommended_tab.dart b/lib/screens/library_tabs/library_recommended_tab.dart index 84ae7c77..89957997 100644 --- a/lib/screens/library_tabs/library_recommended_tab.dart +++ b/lib/screens/library_tabs/library_recommended_tab.dart @@ -14,10 +14,7 @@ import '../../widgets/content_state_builder.dart'; class LibraryRecommendedTab extends StatefulWidget { final PlexLibrary library; - const LibraryRecommendedTab({ - super.key, - required this.library, - }); + const LibraryRecommendedTab({super.key, required this.library}); @override State createState() => _LibraryRecommendedTabState(); @@ -124,10 +121,7 @@ class _LibraryRecommendedTabState extends State itemCount: items.length, itemBuilder: (context, index) { final hub = items[index]; - return HubSection( - hub: hub, - icon: _getHubIcon(hub), - ); + return HubSection(hub: hub, icon: _getHubIcon(hub)); }, ), ), diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 7133550b..f81db457 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -8,6 +8,7 @@ import '../providers/plex_client_provider.dart'; import '../theme/theme_helper.dart'; import '../utils/app_logger.dart'; import '../utils/content_rating_formatter.dart'; +import '../utils/duration_formatter.dart'; import '../utils/provider_extensions.dart'; import '../utils/shuffle_play_helper.dart'; import '../utils/video_player_navigation.dart'; @@ -489,7 +490,7 @@ class _MediaDetailScreenState extends State { borderRadius: BorderRadius.circular(6), ), child: Text( - _formatDuration(metadata.duration!), + formatDurationTextual(metadata.duration!), style: const TextStyle( color: Colors.white, fontSize: 13, @@ -1122,18 +1123,6 @@ class _MediaDetailScreenState extends State { ); } - String _formatDuration(int milliseconds) { - final duration = Duration(milliseconds: milliseconds); - final hours = duration.inHours; - final minutes = duration.inMinutes.remainder(60); - - if (hours > 0) { - return '${hours}h ${minutes}m'; - } else { - return '${minutes}m'; - } - } - String _getPlayButtonLabel(PlexMetadata metadata) { // For TV shows if (metadata.type.toLowerCase() == 'show') { diff --git a/lib/screens/playlist_detail_screen.dart b/lib/screens/playlist_detail_screen.dart index 5af3ed2f..b5001d8e 100644 --- a/lib/screens/playlist_detail_screen.dart +++ b/lib/screens/playlist_detail_screen.dart @@ -217,7 +217,6 @@ class _PlaylistDetailScreenState } } - Future _playFromItem(int index) async { if (items.isEmpty || index < 0 || index >= items.length) return; @@ -235,7 +234,9 @@ class _PlaylistDetailScreenState key: selectedItem.key, ); - if (playQueue == null || playQueue.items == null || playQueue.items!.isEmpty) { + if (playQueue == null || + playQueue.items == null || + playQueue.items!.isEmpty) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(t.messages.failedToCreatePlayQueue)), diff --git a/lib/screens/season_detail_screen.dart b/lib/screens/season_detail_screen.dart index dd2ec503..9ffd3442 100644 --- a/lib/screens/season_detail_screen.dart +++ b/lib/screens/season_detail_screen.dart @@ -6,6 +6,7 @@ import '../models/plex_metadata.dart'; import '../providers/plex_client_provider.dart'; import '../utils/provider_extensions.dart'; import '../utils/video_player_navigation.dart'; +import '../utils/duration_formatter.dart'; import '../widgets/desktop_app_bar.dart'; import '../widgets/media_context_menu.dart'; import '../mixins/item_updatable.dart'; @@ -330,7 +331,9 @@ class _SeasonDetailScreenState extends State children: [ if (episode.duration != null) Text( - _formatDuration(episode.duration!), + formatDurationTimestamp( + Duration(milliseconds: episode.duration!), + ), style: Theme.of(context).textTheme.bodySmall ?.copyWith( color: tokens(context).textMuted, @@ -369,17 +372,4 @@ class _SeasonDetailScreenState extends State ), ); } - - String _formatDuration(int milliseconds) { - final duration = Duration(milliseconds: milliseconds); - final hours = duration.inHours; - final minutes = duration.inMinutes.remainder(60); - final seconds = duration.inSeconds.remainder(60); - - if (hours > 0) { - return '$hours:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}'; - } else { - return '$minutes:${seconds.toString().padLeft(2, '0')}'; - } - } } diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 6d04870f..889508f1 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -320,7 +320,9 @@ class VideoPlayerScreenState extends State widget.metadata.ratingKey, loopQueue: false, // Don't loop playlists by default ); - previous = await playbackState.getPreviousEpisode(widget.metadata.ratingKey); + previous = await playbackState.getPreviousEpisode( + widget.metadata.ratingKey, + ); } // Check if shuffle mode is active else if (playbackState.isShuffleActive) { diff --git a/lib/utils/duration_formatter.dart b/lib/utils/duration_formatter.dart new file mode 100644 index 00000000..afe5a8d8 --- /dev/null +++ b/lib/utils/duration_formatter.dart @@ -0,0 +1,83 @@ +import 'package:duration/duration.dart'; +import 'package:duration/locale.dart'; +import '../i18n/strings.g.dart'; + +/// Formats a duration in human-readable textual format (e.g., "1h 23m" or "1 hour 23 minutes"). +/// Uses localized unit names based on the current app locale. +/// Shows hours and minutes only (no seconds). +/// +/// Used for: media cards, media details, playlists. +String formatDurationTextual(int milliseconds, {bool abbreviated = true}) { + final duration = Duration(milliseconds: milliseconds); + + // Get the appropriate locale for the duration package + final durationLocale = _getDurationLocale(); + + // Format with abbreviated or full units (h, m) but no seconds + return prettyDuration( + duration, + abbreviated: abbreviated, + locale: durationLocale, + delimiter: abbreviated ? ' ' : ', ', + spacer: '', + // Configure to show only hours and minutes + tersity: DurationTersity.minute, + ); +} + +/// Formats a duration in human-readable textual format with seconds (e.g., "1h 23m 45s"). +/// Uses localized unit names based on the current app locale. +/// Shows hours, minutes, and seconds. +/// +/// Used for: sleep timer countdown. +String formatDurationWithSeconds(Duration duration) { + // Get the appropriate locale for the duration package + final durationLocale = _getDurationLocale(); + + // Format with abbreviated units (h, m, s) including seconds + return prettyDuration( + duration, + abbreviated: true, + locale: durationLocale, + delimiter: ' ', + spacer: '', + // Show all non-zero units + tersity: DurationTersity.second, + ); +} + +/// Formats a duration in timestamp format (e.g., "1:23:45" or "23:45"). +/// This format is not localized as it follows universal digital clock conventions. +/// Shows H:MM:SS or M:SS depending on duration. +/// +/// Used for: video controls, chapters, episode durations. +String formatDurationTimestamp(Duration duration) { + final hours = duration.inHours; + final minutes = duration.inMinutes.remainder(60); + final seconds = duration.inSeconds.remainder(60); + + if (hours > 0) { + return '$hours:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}'; + } else { + return '$minutes:${seconds.toString().padLeft(2, '0')}'; + } +} + +/// Gets the duration package locale based on the current app locale. +/// Falls back to English if the locale is not supported by the duration package. +DurationLocale _getDurationLocale() { + // Get the current locale from slang's LocaleSettings + final appLocale = LocaleSettings.currentLocale; + final languageCode = appLocale.languageCode; + + // Map supported locales to duration package locales + // The duration package supports many languages, but we'll focus on the ones + // that our app supports: en, de, it, nl, sv, zh + try { + return DurationLocale.fromLanguageCode(languageCode) ?? + const EnglishDurationLocale(); + } catch (e) { + // Fallback to English if language code is not supported + return const EnglishDurationLocale(); + } +} diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index 4572273a..e9659b47 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -9,6 +9,7 @@ import '../services/settings_service.dart'; import '../utils/provider_extensions.dart'; import '../utils/video_player_navigation.dart'; import '../utils/content_rating_formatter.dart'; +import '../utils/duration_formatter.dart'; import '../screens/media_detail_screen.dart'; import '../screens/season_detail_screen.dart'; import '../screens/playlist_detail_screen.dart'; @@ -23,10 +24,12 @@ class MediaCard extends StatefulWidget { final double? height; final void Function(String ratingKey)? onRefresh; final VoidCallback? onRemoveFromContinueWatching; - final VoidCallback? onListRefresh; // Callback to refresh the entire parent list + final VoidCallback? + onListRefresh; // Callback to refresh the entire parent list final bool forceGridMode; final bool isInContinueWatching; - final String? collectionId; // The collection ID if displaying within a collection + final String? + collectionId; // The collection ID if displaying within a collection const MediaCard({ super.key, @@ -55,9 +58,8 @@ class _MediaCardState extends State { await Navigator.push( context, MaterialPageRoute( - builder: (context) => PlaylistDetailScreen( - playlist: widget.item as PlexPlaylist, - ), + builder: (context) => + PlaylistDetailScreen(playlist: widget.item as PlexPlaylist), ), ); return; @@ -70,9 +72,7 @@ class _MediaCardState extends State { final result = await Navigator.push( context, MaterialPageRoute( - builder: (context) => CollectionDetailScreen( - collection: widget.item, - ), + builder: (context) => CollectionDetailScreen(collection: widget.item), ), ); @@ -226,16 +226,18 @@ class _MediaCardGrid extends StatelessWidget { Builder( builder: (context) { final playlist = item as PlexPlaylist; - if (playlist.leafCount != null && playlist.leafCount! > 0) { + if (playlist.leafCount != null && + playlist.leafCount! > 0) { return Text( t.playlists.itemCount(count: playlist.leafCount!), maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: tokens(context).textMuted, - fontSize: 11, - height: 1.1, - ), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: tokens(context).textMuted, + fontSize: 11, + height: 1.1, + ), ); } return const SizedBox.shrink(); @@ -248,17 +250,19 @@ class _MediaCardGrid extends StatelessWidget { // For collections, show item count if (metadata.type.toLowerCase() == 'collection') { - final count = metadata.childCount ?? metadata.leafCount; + final count = + metadata.childCount ?? metadata.leafCount; if (count != null && count > 0) { return Text( t.playlists.itemCount(count: count), maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: tokens(context).textMuted, - fontSize: 11, - height: 1.1, - ), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: tokens(context).textMuted, + fontSize: 11, + height: 1.1, + ), ); } } @@ -269,31 +273,34 @@ class _MediaCardGrid extends StatelessWidget { metadata.displaySubtitle!, maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: tokens(context).textMuted, - fontSize: 11, - height: 1.1, - ), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: tokens(context).textMuted, + fontSize: 11, + height: 1.1, + ), ); } else if (metadata.parentTitle != null) { return Text( metadata.parentTitle!, maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: tokens(context).textMuted, - fontSize: 11, - height: 1.1, - ), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: tokens(context).textMuted, + fontSize: 11, + height: 1.1, + ), ); } else if (metadata.year != null) { return Text( '${metadata.year}', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: tokens(context).textMuted, - fontSize: 11, - height: 1.1, - ), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: tokens(context).textMuted, + fontSize: 11, + height: 1.1, + ), ); } @@ -332,7 +339,9 @@ class _MediaCardGrid extends StatelessWidget { fallbackIcon = Icons.playlist_play; } else if (item is PlexMetadata) { final useSeasonPoster = context.watch().useSeasonPoster; - posterUrl = (item as PlexMetadata).posterThumb(useSeasonPoster: useSeasonPoster); + posterUrl = (item as PlexMetadata).posterThumb( + useSeasonPoster: useSeasonPoster, + ); } if (posterUrl != null) { @@ -454,18 +463,6 @@ class _MediaCardList extends StatelessWidget { } } - String _formatDuration(int milliseconds) { - final duration = Duration(milliseconds: milliseconds); - final hours = duration.inHours; - final minutes = duration.inMinutes.remainder(60); - - if (hours > 0) { - return '${hours}h ${minutes}m'; - } else { - return '${minutes}m'; - } - } - String _buildMetadataLine() { final parts = []; @@ -477,8 +474,8 @@ class _MediaCardList extends StatelessWidget { } // Add duration - if (playlist.formattedDuration != null) { - parts.add(playlist.formattedDuration!); + if (playlist.duration != null) { + parts.add(formatDurationTextual(playlist.duration!)); } // Add smart playlist badge @@ -489,15 +486,16 @@ class _MediaCardList extends StatelessWidget { final metadata = item as PlexMetadata; // For collections, show item count - if (metadata.type.toLowerCase() == 'collection') { - final count = metadata.childCount ?? metadata.leafCount; - if (count != null && count > 0) { - parts.add(t.playlists.itemCount(count: count)); - } - } else { + if (metadata.type.toLowerCase() == 'collection') { + final count = metadata.childCount ?? metadata.leafCount; + if (count != null && count > 0) { + parts.add(t.playlists.itemCount(count: count)); + } + } else { // For other media types, show standard metadata // Add content rating - if (metadata.contentRating != null && metadata.contentRating!.isNotEmpty) { + if (metadata.contentRating != null && + metadata.contentRating!.isNotEmpty) { final rating = formatContentRating(metadata.contentRating); if (rating.isNotEmpty) { parts.add(rating); @@ -511,7 +509,7 @@ class _MediaCardList extends StatelessWidget { // Add duration if (metadata.duration != null) { - parts.add(_formatDuration(metadata.duration!)); + parts.add(formatDurationTextual(metadata.duration!)); } // Add user rating @@ -668,7 +666,9 @@ class _MediaCardList extends StatelessWidget { fallbackIcon = Icons.playlist_play; } else if (item is PlexMetadata) { final useSeasonPoster = context.watch().useSeasonPoster; - posterUrl = (item as PlexMetadata).posterThumb(useSeasonPoster: useSeasonPoster); + posterUrl = (item as PlexMetadata).posterThumb( + useSeasonPoster: useSeasonPoster, + ); } if (posterUrl != null) { diff --git a/lib/widgets/playlist_item_card.dart b/lib/widgets/playlist_item_card.dart index 234ae989..004f771e 100644 --- a/lib/widgets/playlist_item_card.dart +++ b/lib/widgets/playlist_item_card.dart @@ -3,6 +3,7 @@ import 'package:provider/provider.dart'; import 'package:cached_network_image/cached_network_image.dart'; import '../models/plex_metadata.dart'; import '../providers/plex_client_provider.dart'; +import '../utils/duration_formatter.dart'; import '../i18n/strings.g.dart'; /// Custom list item widget for playlist items @@ -97,7 +98,7 @@ class PlaylistItemCard extends StatelessWidget { // Duration if (item.duration != null) Text( - _formatDuration(item.duration!), + formatDurationTextual(item.duration!), style: TextStyle(fontSize: 13, color: Colors.grey[400]), ), @@ -175,16 +176,4 @@ class PlaylistItemCard extends StatelessWidget { // Default to type return item.type; } - - String _formatDuration(int milliseconds) { - final duration = Duration(milliseconds: milliseconds); - final hours = duration.inHours; - final minutes = duration.inMinutes.remainder(60); - - if (hours > 0) { - return '${hours}h ${minutes}m'; - } else { - return '${minutes}m'; - } - } } diff --git a/lib/widgets/video_controls/sheets/chapter_sheet.dart b/lib/widgets/video_controls/sheets/chapter_sheet.dart index a89a9e53..1fa83e61 100644 --- a/lib/widgets/video_controls/sheets/chapter_sheet.dart +++ b/lib/widgets/video_controls/sheets/chapter_sheet.dart @@ -3,6 +3,7 @@ import 'package:media_kit/media_kit.dart'; import 'package:provider/provider.dart'; import '../../../models/plex_media_info.dart'; import '../../../providers/plex_client_provider.dart'; +import '../../../utils/duration_formatter.dart'; import 'base_video_control_sheet.dart'; /// Bottom sheet for selecting chapters @@ -34,18 +35,6 @@ class ChapterSheet extends StatelessWidget { ); } - String _formatDuration(Duration duration) { - final hours = duration.inHours; - final minutes = duration.inMinutes.remainder(60); - final seconds = duration.inSeconds.remainder(60); - - if (hours > 0) { - return '$hours:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}'; - } else { - return '$minutes:${seconds.toString().padLeft(2, '0')}'; - } - } - @override Widget build(BuildContext context) { return StreamBuilder( @@ -152,7 +141,7 @@ class ChapterSheet extends StatelessWidget { ), ), subtitle: Text( - _formatDuration(chapter.startTime), + formatDurationTimestamp(chapter.startTime), style: TextStyle( color: isCurrentChapter ? Colors.blue.withValues(alpha: 0.7) diff --git a/lib/widgets/video_controls/sheets/sleep_timer_sheet.dart b/lib/widgets/video_controls/sheets/sleep_timer_sheet.dart index 14c768ad..649d7096 100644 --- a/lib/widgets/video_controls/sheets/sleep_timer_sheet.dart +++ b/lib/widgets/video_controls/sheets/sleep_timer_sheet.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:media_kit/media_kit.dart'; +import '../../../i18n/strings.g.dart'; import '../../../services/settings_service.dart'; import '../../../services/sleep_timer_service.dart'; import 'base_video_control_sheet.dart'; @@ -37,7 +38,7 @@ class SleepTimerSheet extends StatelessWidget { listenable: sleepTimer, builder: (context, _) { return BaseVideoControlSheet( - title: 'Sleep Timer', + title: t.videoControls.sleepTimer, icon: sleepTimer.isActive ? Icons.bedtime : Icons.bedtime_outlined, iconColor: sleepTimer.isActive ? Colors.amber : null, child: SleepTimerContent( diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index fde60db0..88029ca2 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -16,6 +16,7 @@ import '../../services/keyboard_shortcuts_service.dart'; import '../../services/settings_service.dart'; import '../../services/sleep_timer_service.dart'; import '../../utils/desktop_window_padding.dart'; +import '../../utils/duration_formatter.dart'; import '../../utils/platform_detector.dart'; import '../../utils/provider_extensions.dart'; import '../../i18n/strings.g.dart'; @@ -1181,14 +1182,14 @@ class _PlexVideoControlsState extends State mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - _formatDuration(position), + formatDurationTimestamp(position), style: const TextStyle( color: Colors.white, fontSize: 14, ), ), Text( - _formatDuration(duration), + formatDurationTimestamp(duration), style: const TextStyle( color: Colors.white, fontSize: 14, @@ -1327,7 +1328,7 @@ class _PlexVideoControlsState extends State return Row( children: [ Text( - _formatDuration(position), + formatDurationTimestamp(position), style: const TextStyle( color: Colors.white, fontSize: 14, @@ -1342,7 +1343,7 @@ class _PlexVideoControlsState extends State ), const SizedBox(width: 12), Text( - _formatDuration(duration), + formatDurationTimestamp(duration), style: const TextStyle( color: Colors.white, fontSize: 14, @@ -1624,15 +1625,4 @@ class _PlexVideoControlsState extends State } } - String _formatDuration(Duration duration) { - final hours = duration.inHours; - final minutes = duration.inMinutes.remainder(60); - final seconds = duration.inSeconds.remainder(60); - - if (hours > 0) { - return '$hours:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}'; - } else { - return '$minutes:${seconds.toString().padLeft(2, '0')}'; - } - } } diff --git a/lib/widgets/video_controls/widgets/sleep_timer_active_status.dart b/lib/widgets/video_controls/widgets/sleep_timer_active_status.dart index b218aa9a..fde98207 100644 --- a/lib/widgets/video_controls/widgets/sleep_timer_active_status.dart +++ b/lib/widgets/video_controls/widgets/sleep_timer_active_status.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../../services/sleep_timer_service.dart'; import '../../../i18n/strings.g.dart'; +import '../../../utils/duration_formatter.dart'; /// Widget displaying active sleep timer status with extend/cancel actions class SleepTimerActiveStatus extends StatelessWidget { @@ -15,20 +16,6 @@ class SleepTimerActiveStatus extends StatelessWidget { this.onCancel, }); - String _formatDuration(Duration duration) { - final hours = duration.inHours; - final minutes = duration.inMinutes.remainder(60); - final seconds = duration.inSeconds.remainder(60); - - if (hours > 0) { - return '${hours}h ${minutes}m ${seconds}s'; - } else if (minutes > 0) { - return '${minutes}m ${seconds}s'; - } else { - return '${seconds}s'; - } - } - @override Widget build(BuildContext context) { return Container( @@ -36,9 +23,9 @@ class SleepTimerActiveStatus extends StatelessWidget { color: Colors.amber.withValues(alpha: 0.1), child: Column( children: [ - const Text( - 'Timer Active', - style: TextStyle( + Text( + t.videoControls.timerActive, + style: const TextStyle( color: Colors.amber, fontSize: 16, fontWeight: FontWeight.bold, @@ -46,11 +33,10 @@ class SleepTimerActiveStatus extends StatelessWidget { ), const SizedBox(height: 8), Text( - 'Playback will pause in ${_formatDuration(remainingTime)}', - style: const TextStyle( - color: Colors.white70, - fontSize: 14, + t.videoControls.playbackWillPauseIn( + duration: formatDurationWithSeconds(remainingTime), ), + style: const TextStyle(color: Colors.white70, fontSize: 14), ), const SizedBox(height: 16), Row( @@ -59,28 +45,21 @@ class SleepTimerActiveStatus extends StatelessWidget { OutlinedButton.icon( icon: const Icon(Icons.add), label: Text( - t.videoControls.addTime( - amount: "15", - unit: " min", - ), + t.videoControls.addTime(amount: "15", unit: " min"), ), style: OutlinedButton.styleFrom( foregroundColor: Colors.white, side: const BorderSide(color: Colors.white54), ), onPressed: () { - sleepTimer.extendTimer( - const Duration(minutes: 15), - ); + sleepTimer.extendTimer(const Duration(minutes: 15)); }, ), const SizedBox(width: 12), FilledButton.icon( icon: const Icon(Icons.cancel), label: Text(t.common.cancel), - style: FilledButton.styleFrom( - backgroundColor: Colors.red, - ), + style: FilledButton.styleFrom(backgroundColor: Colors.red), onPressed: () { sleepTimer.cancelTimer(); onCancel?.call(); diff --git a/lib/widgets/video_controls/widgets/sleep_timer_duration_list.dart b/lib/widgets/video_controls/widgets/sleep_timer_duration_list.dart index f1a674bf..5c8a6037 100644 --- a/lib/widgets/video_controls/widgets/sleep_timer_duration_list.dart +++ b/lib/widgets/video_controls/widgets/sleep_timer_duration_list.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:media_kit/media_kit.dart'; import '../../../services/sleep_timer_service.dart'; +import '../../../utils/duration_formatter.dart'; import '../../../i18n/strings.g.dart'; /// Widget displaying list of sleep timer durations for selection @@ -16,15 +17,6 @@ class SleepTimerDurationList extends StatelessWidget { this.defaultDuration, }); - String _formatLabel(int minutes) { - if (minutes < 60) { - return '$minutes minutes'; - } - final hours = minutes / 60; - final isWholeHour = minutes % 60 == 0; - return '${hours.toStringAsFixed(isWholeHour ? 0 : 1)} ${minutes == 60 ? 'hour' : 'hours'}'; - } - @override Widget build(BuildContext context) { final durations = [5, 10, 15, 30, 45, 60, 90, 120]; @@ -38,7 +30,10 @@ class SleepTimerDurationList extends StatelessWidget { itemCount: durations.length, itemBuilder: (context, index) { final minutes = durations[index]; - final label = _formatLabel(minutes); + final label = formatDurationTextual( + minutes * 60 * 1000, // Convert minutes to milliseconds + abbreviated: false, // Use full format for better readability + ); return ListTile( leading: const Icon(Icons.timer, color: Colors.white70), @@ -57,11 +52,9 @@ class SleepTimerDurationList extends StatelessWidget { // Show a snackbar notification if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Sleep timer completed - playback paused', - ), - duration: Duration(seconds: 3), + SnackBar( + content: Text(t.videoControls.sleepTimerCompleted), + duration: const Duration(seconds: 3), ), ); } @@ -71,9 +64,7 @@ class SleepTimerDurationList extends StatelessWidget { // Show confirmation snackbar ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text( - t.messages.sleepTimerSet(label: label), - ), + content: Text(t.messages.sleepTimerSet(label: label)), duration: const Duration(seconds: 2), ), ); diff --git a/pubspec.lock b/pubspec.lock index e818babd..fea4690d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -257,6 +257,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.1" + duration: + dependency: "direct main" + description: + name: duration + sha256: "13e5d20723c9c1dde8fb318cf86716d10ce294734e81e44ae1a817f3ae714501" + url: "https://pub.dev" + source: hosted + version: "4.0.3" fake_async: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index d6ae92be..ff7927f3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -28,6 +28,7 @@ dependencies: qr_flutter: ^4.1.0 slang: ^3.31.2 slang_flutter: ^3.31.0 + duration: ^4.0.3 connectivity_plus: ^6.0.5 os_media_controls: git: From 9501831691ee334704a275331a994a3af3f4dedd Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 16 Nov 2025 23:57:32 +0100 Subject: [PATCH 27/29] refactor: format --- lib/services/storage_service.dart | 8 +- .../collection_playlist_play_helper.dart | 19 +- lib/utils/error_message_utils.dart | 5 +- lib/utils/grid_size_calculator.dart | 3 +- lib/utils/library_refresh_notifier.dart | 3 +- lib/widgets/content_state_builder.dart | 10 +- lib/widgets/empty_state_widget.dart | 7 +- lib/widgets/error_state_widget.dart | 10 +- lib/widgets/folder_tree_item.dart | 4 +- lib/widgets/folder_tree_view.dart | 29 +- lib/widgets/hub_section.dart | 19 +- lib/widgets/media_context_menu.dart | 302 ++++++++++-------- lib/widgets/sort_bottom_sheet.dart | 5 +- .../sheets/audio_sync_sheet.dart | 14 +- .../video_controls/sheets/chapter_sheet.dart | 30 +- .../sheets/playback_speed_sheet.dart | 5 +- .../sheets/subtitle_track_sheet.dart | 18 +- .../video_controls/sheets/version_sheet.dart | 4 +- .../sheets/video_settings_sheet.dart | 30 +- .../video_controls/video_controls.dart | 1 - 20 files changed, 244 insertions(+), 282 deletions(-) diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index d967425e..f17ced12 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -180,11 +180,13 @@ class StorageService { } Map getLibraryFilters({String? sectionId}) { - final scopedKey = - sectionId != null ? 'library_filters_$sectionId' : _keyLibraryFilters; + final scopedKey = sectionId != null + ? 'library_filters_$sectionId' + : _keyLibraryFilters; // Prefer per-library filters when available - final jsonString = _prefs.getString(scopedKey) ?? + final jsonString = + _prefs.getString(scopedKey) ?? // Legacy support: fall back to global filters if present _prefs.getString(_keyLibraryFilters); if (jsonString == null) return {}; diff --git a/lib/utils/collection_playlist_play_helper.dart b/lib/utils/collection_playlist_play_helper.dart index 701afa37..61a98cf4 100644 --- a/lib/utils/collection_playlist_play_helper.dart +++ b/lib/utils/collection_playlist_play_helper.dart @@ -62,13 +62,13 @@ Future playCollectionOrPlaylist({ // Set play queue in provider final playbackState = context.read(); playbackState.setClient(client); - await playbackState.setPlaybackFromPlayQueue( - fetchedQueue, - ratingKey, - ); + await playbackState.setPlaybackFromPlayQueue(fetchedQueue, ratingKey); // Navigate to first item - await navigateToVideoPlayer(context, metadata: fetchedQueue.items!.first); + await navigateToVideoPlayer( + context, + metadata: fetchedQueue.items!.first, + ); return; } } @@ -78,9 +78,7 @@ Future playCollectionOrPlaylist({ playQueue.items!.isEmpty) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(t.messages.failedToCreatePlayQueueNoItems), - ), + SnackBar(content: Text(t.messages.failedToCreatePlayQueueNoItems)), ); } return; @@ -91,10 +89,7 @@ Future playCollectionOrPlaylist({ // Set play queue in provider final playbackState = context.read(); playbackState.setClient(client); - await playbackState.setPlaybackFromPlayQueue( - playQueue, - ratingKey, - ); + await playbackState.setPlaybackFromPlayQueue(playQueue, ratingKey); // Navigate to first item await navigateToVideoPlayer(context, metadata: playQueue.items!.first); diff --git a/lib/utils/error_message_utils.dart b/lib/utils/error_message_utils.dart index 7290d374..567ef238 100644 --- a/lib/utils/error_message_utils.dart +++ b/lib/utils/error_message_utils.dart @@ -22,8 +22,5 @@ String mapDioErrorToMessage(DioException error, {required String context}) { /// Generic fallback for unexpected errors. String mapUnexpectedErrorToMessage(dynamic error, {required String context}) { appLogger.e('Unexpected error in $context', error: error); - return t.errors.failedToLoad( - context: context, - error: error.toString(), - ); + return t.errors.failedToLoad(context: context, error: error.toString()); } diff --git a/lib/utils/grid_size_calculator.dart b/lib/utils/grid_size_calculator.dart index c397dc49..35f403a2 100644 --- a/lib/utils/grid_size_calculator.dart +++ b/lib/utils/grid_size_calculator.dart @@ -17,7 +17,8 @@ class GridSizeCalculator { ) { final screenWidth = MediaQuery.of(context).size.width; final isDesktop = screenWidth > desktopBreakpoint; - final isTablet = screenWidth > tabletBreakpoint && screenWidth <= desktopBreakpoint; + final isTablet = + screenWidth > tabletBreakpoint && screenWidth <= desktopBreakpoint; switch (density) { case LibraryDensity.comfortable: diff --git a/lib/utils/library_refresh_notifier.dart b/lib/utils/library_refresh_notifier.dart index fb4d08f4..712348f0 100644 --- a/lib/utils/library_refresh_notifier.dart +++ b/lib/utils/library_refresh_notifier.dart @@ -3,7 +3,8 @@ import 'dart:async'; /// Notifier for triggering refreshes of library tabs /// Singleton pattern for global access class LibraryRefreshNotifier { - static final LibraryRefreshNotifier _instance = LibraryRefreshNotifier._internal(); + static final LibraryRefreshNotifier _instance = + LibraryRefreshNotifier._internal(); factory LibraryRefreshNotifier() => _instance; diff --git a/lib/widgets/content_state_builder.dart b/lib/widgets/content_state_builder.dart index 0cae5805..5ad59cee 100644 --- a/lib/widgets/content_state_builder.dart +++ b/lib/widgets/content_state_builder.dart @@ -57,10 +57,7 @@ class ContentStateBuilder extends StatelessWidget { style: const TextStyle(color: Colors.white70), ), const SizedBox(height: 16), - ElevatedButton( - onPressed: onRetry, - child: Text(t.common.retry), - ), + ElevatedButton(onPressed: onRetry, child: Text(t.common.retry)), ], ), ); @@ -74,10 +71,7 @@ class ContentStateBuilder extends StatelessWidget { children: [ Icon(emptyIcon, size: 64, color: Colors.grey), const SizedBox(height: 16), - Text( - emptyMessage, - style: const TextStyle(color: Colors.white70), - ), + Text(emptyMessage, style: const TextStyle(color: Colors.white70)), ], ), ); diff --git a/lib/widgets/empty_state_widget.dart b/lib/widgets/empty_state_widget.dart index 78e173ad..72535100 100644 --- a/lib/widgets/empty_state_widget.dart +++ b/lib/widgets/empty_state_widget.dart @@ -42,11 +42,8 @@ class EmptyStateWidget extends StatelessWidget { message, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: Theme.of(context) - .colorScheme - .onSurface - .withOpacity(0.6), - ), + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6), + ), ), if (onAction != null && actionLabel != null) ...[ const SizedBox(height: 24), diff --git a/lib/widgets/error_state_widget.dart b/lib/widgets/error_state_widget.dart index be742f7f..31559bfe 100644 --- a/lib/widgets/error_state_widget.dart +++ b/lib/widgets/error_state_widget.dart @@ -31,19 +31,15 @@ class ErrorStateWidget extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ if (icon != null) ...[ - Icon( - icon, - size: 64, - color: Theme.of(context).colorScheme.error, - ), + Icon(icon, size: 64, color: Theme.of(context).colorScheme.error), const SizedBox(height: 16), ], Text( message, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: Theme.of(context).colorScheme.error, - ), + color: Theme.of(context).colorScheme.error, + ), ), if (onRetry != null) ...[ const SizedBox(height: 24), diff --git a/lib/widgets/folder_tree_item.dart b/lib/widgets/folder_tree_item.dart index 384cbdb8..4c765325 100644 --- a/lib/widgets/folder_tree_item.dart +++ b/lib/widgets/folder_tree_item.dart @@ -113,7 +113,9 @@ class FolderTreeItem extends StatelessWidget { item.year.toString(), style: TextStyle( fontSize: 12, - color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6), + color: Theme.of( + context, + ).colorScheme.onSurface.withOpacity(0.6), ), ), ], diff --git a/lib/widgets/folder_tree_view.dart b/lib/widgets/folder_tree_view.dart index b9854406..371a2e48 100644 --- a/lib/widgets/folder_tree_view.dart +++ b/lib/widgets/folder_tree_view.dart @@ -15,11 +15,7 @@ class FolderTreeView extends StatefulWidget { final String libraryKey; final void Function(String)? onRefresh; - const FolderTreeView({ - super.key, - required this.libraryKey, - this.onRefresh, - }); + const FolderTreeView({super.key, required this.libraryKey, this.onRefresh}); @override State createState() => _FolderTreeViewState(); @@ -109,7 +105,9 @@ class _FolderTreeViewState extends State { _loadingFolders.remove(folder.key); }); - appLogger.d('Loaded ${children.length} children for folder: ${folder.title}'); + appLogger.d( + 'Loaded ${children.length} children for folder: ${folder.title}', + ); } catch (e) { if (!mounted) return; @@ -148,10 +146,7 @@ class _FolderTreeViewState extends State { // For episodes, start playback directly if (itemType == 'episode') { - final result = await navigateToVideoPlayer( - context, - metadata: item, - ); + final result = await navigateToVideoPlayer(context, metadata: item); if (result == true) { widget.onRefresh?.call(item.ratingKey); } @@ -181,11 +176,15 @@ class _FolderTreeViewState extends State { // Folders typically don't have a specific type or might have special indicators // Check for common folder indicators return item.key.contains('/folder') || - item.type.isEmpty || - item.type.toLowerCase() == 'folder'; + item.type.isEmpty || + item.type.toLowerCase() == 'folder'; } - List _buildTreeItems(List items, int depth, [String parentPath = '']) { + List _buildTreeItems( + List items, + int depth, [ + String parentPath = '', + ]) { final List widgets = []; for (int i = 0; i < items.length; i++) { @@ -260,9 +259,7 @@ class _FolderTreeViewState extends State { return RefreshIndicator( onRefresh: _loadRootFolders, - child: ListView( - children: _buildTreeItems(_rootFolders, 0), - ), + child: ListView(children: _buildTreeItems(_rootFolders, 0)), ); } } diff --git a/lib/widgets/hub_section.dart b/lib/widgets/hub_section.dart index c2c4f1d1..ce7f7461 100644 --- a/lib/widgets/hub_section.dart +++ b/lib/widgets/hub_section.dart @@ -45,10 +45,7 @@ class HubSection extends StatelessWidget { : null, borderRadius: BorderRadius.circular(8), child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), child: Row( children: [ Icon(icon), @@ -76,10 +73,10 @@ class HubSection extends StatelessWidget { final cardWidth = screenWidth > 1600 ? 220.0 : screenWidth > 1200 - ? 200.0 - : screenWidth > 800 - ? 190.0 - : 160.0; + ? 200.0 + : screenWidth > 800 + ? 190.0 + : 160.0; // MediaCard has 8px padding on all sides (16px total horizontally) // So actual poster width is cardWidth - 16 @@ -125,9 +122,9 @@ class HubSection extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Text( t.messages.noItemsAvailable, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Colors.grey, - ), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: Colors.grey), ), ), ], diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index 67f0327f..87a2013a 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -31,7 +31,8 @@ class MediaContextMenu extends StatefulWidget { final VoidCallback? onTap; final Widget child; final bool isInContinueWatching; - final String? collectionId; // The collection ID if displaying within a collection + final String? + collectionId; // The collection ID if displaying within a collection const MediaContextMenu({ super.key, @@ -65,7 +66,8 @@ class _MediaContextMenuState extends State { final itemType = isPlaylist ? 'playlist' : (metadata!.type.toLowerCase()); final isCollection = itemType == 'collection'; - final isPartiallyWatched = !isPlaylist && + final isPartiallyWatched = + !isPlaylist && metadata!.viewedLeafCount != null && metadata.leafCount != null && metadata.viewedLeafCount! > 0 && @@ -110,108 +112,104 @@ class _MediaContextMenuState extends State { } else { // Regular menu items for other types - // Mark as Watched - if (!metadata!.isWatched || isPartiallyWatched) { - menuActions.add( - _MenuAction( - value: 'watch', - icon: Icons.check_circle_outline, - label: t.mediaMenu.markAsWatched, - ), - ); - } + // Mark as Watched + if (!metadata!.isWatched || isPartiallyWatched) { + menuActions.add( + _MenuAction( + value: 'watch', + icon: Icons.check_circle_outline, + label: t.mediaMenu.markAsWatched, + ), + ); + } - // Mark as Unwatched - if (metadata.isWatched || isPartiallyWatched) { - menuActions.add( - _MenuAction( - value: 'unwatch', - icon: Icons.remove_circle_outline, - label: t.mediaMenu.markAsUnwatched, - ), - ); - } + // Mark as Unwatched + if (metadata.isWatched || isPartiallyWatched) { + menuActions.add( + _MenuAction( + value: 'unwatch', + icon: Icons.remove_circle_outline, + label: t.mediaMenu.markAsUnwatched, + ), + ); + } - // Remove from Continue Watching (only in continue watching section) - if (widget.isInContinueWatching) { - menuActions.add( - _MenuAction( - value: 'remove_from_continue_watching', - icon: Icons.close, - label: t.mediaMenu.removeFromContinueWatching, - ), - ); - } + // Remove from Continue Watching (only in continue watching section) + if (widget.isInContinueWatching) { + menuActions.add( + _MenuAction( + value: 'remove_from_continue_watching', + icon: Icons.close, + label: t.mediaMenu.removeFromContinueWatching, + ), + ); + } - // Remove from Collection (only when viewing items within a collection) - if (widget.collectionId != null) { - menuActions.add( - _MenuAction( - value: 'remove_from_collection', - icon: Icons.delete_outline, - label: t.collections.removeFromCollection, - ), - ); - } + // Remove from Collection (only when viewing items within a collection) + if (widget.collectionId != null) { + menuActions.add( + _MenuAction( + value: 'remove_from_collection', + icon: Icons.delete_outline, + label: t.collections.removeFromCollection, + ), + ); + } - // Go to Series (for episodes and seasons) - if ((itemType == 'episode' || itemType == 'season') && - metadata.grandparentTitle != null) { - menuActions.add( - _MenuAction( - value: 'series', - icon: Icons.tv, - label: t.mediaMenu.goToSeries, - ), - ); - } + // Go to Series (for episodes and seasons) + if ((itemType == 'episode' || itemType == 'season') && + metadata.grandparentTitle != null) { + menuActions.add( + _MenuAction( + value: 'series', + icon: Icons.tv, + label: t.mediaMenu.goToSeries, + ), + ); + } - // Go to Season (for episodes) - if (itemType == 'episode' && metadata.parentTitle != null) { - menuActions.add( - _MenuAction( - value: 'season', - icon: Icons.playlist_play, - label: t.mediaMenu.goToSeason, - ), - ); - } + // Go to Season (for episodes) + if (itemType == 'episode' && metadata.parentTitle != null) { + menuActions.add( + _MenuAction( + value: 'season', + icon: Icons.playlist_play, + label: t.mediaMenu.goToSeason, + ), + ); + } - // Shuffle Play (for shows and seasons) - if (itemType == 'show' || itemType == 'season') { - menuActions.add( - _MenuAction( - value: 'shuffle_play', - icon: Icons.shuffle, - label: t.mediaMenu.shufflePlay, - ), - ); - } + // Shuffle Play (for shows and seasons) + if (itemType == 'show' || itemType == 'season') { + menuActions.add( + _MenuAction( + value: 'shuffle_play', + icon: Icons.shuffle, + label: t.mediaMenu.shufflePlay, + ), + ); + } - // File Info (for episodes and movies) - if (itemType == 'episode' || itemType == 'movie') { - menuActions.add( - _MenuAction( - value: 'fileinfo', - icon: Icons.info_outline, - label: t.mediaMenu.fileInfo, - ), - ); - } + // File Info (for episodes and movies) + if (itemType == 'episode' || itemType == 'movie') { + menuActions.add( + _MenuAction( + value: 'fileinfo', + icon: Icons.info_outline, + label: t.mediaMenu.fileInfo, + ), + ); + } - // Add to... (for episodes, movies, shows, and seasons) - if (itemType == 'episode' || - itemType == 'movie' || - itemType == 'show' || - itemType == 'season') { - menuActions.add( - _MenuAction( - value: 'add_to', - icon: Icons.add, - label: t.common.addTo, - ), - ); - } + // Add to... (for episodes, movies, shows, and seasons) + if (itemType == 'episode' || + itemType == 'movie' || + itemType == 'show' || + itemType == 'season') { + menuActions.add( + _MenuAction(value: 'add_to', icon: Icons.add, label: t.common.addTo), + ); + } } // End of regular menu items else block String? selected; @@ -478,10 +476,8 @@ class _MediaContextMenuState extends State { context: context, isScrollControlled: true, backgroundColor: Colors.transparent, - builder: (context) => FileInfoBottomSheet( - fileInfo: fileInfo, - title: metadata.title, - ), + builder: (context) => + FileInfoBottomSheet(fileInfo: fileInfo, title: metadata.title), ); } else if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -722,7 +718,9 @@ class _MediaContextMenuState extends State { // If still not found, try to extract from the key field if (sectionId == null) { - final keyMatch = RegExp(r'/library/sections/(\d+)').firstMatch(metadata.key); + final keyMatch = RegExp( + r'/library/sections/(\d+)', + ).firstMatch(metadata.key); if (keyMatch != null) { sectionId = int.tryParse(keyMatch.group(1)!); appLogger.d(' - Extracted from key: $sectionId'); @@ -732,8 +730,12 @@ class _MediaContextMenuState extends State { // Last resort: try to get it from the item's parent (for episodes/seasons) if (sectionId == null && metadata.grandparentRatingKey != null) { try { - appLogger.d(' - Trying to get from parent: ${metadata.grandparentRatingKey}'); - final parentMeta = await client.getMetadata(metadata.grandparentRatingKey!); + appLogger.d( + ' - Trying to get from parent: ${metadata.grandparentRatingKey}', + ); + final parentMeta = await client.getMetadata( + metadata.grandparentRatingKey!, + ); sectionId = parentMeta?.librarySectionID; appLogger.d(' - Parent sectionId: $sectionId'); } catch (e) { @@ -747,7 +749,9 @@ class _MediaContextMenuState extends State { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('Unable to determine library section for this item'), + content: Text( + 'Unable to determine library section for this item', + ), ), ); } @@ -755,14 +759,17 @@ class _MediaContextMenuState extends State { } // Load collections for this library section - final collections = await client.getLibraryCollections(sectionId.toString()); + final collections = await client.getLibraryCollections( + sectionId.toString(), + ); if (!context.mounted) return; // Show dialog to select collection or create new final result = await showDialog( context: context, - builder: (context) => _CollectionSelectionDialog(collections: collections), + builder: (context) => + _CollectionSelectionDialog(collections: collections), ); if (result == null || !context.mounted) return; @@ -778,7 +785,9 @@ class _MediaContextMenuState extends State { builder: (context) => _CreateCollectionDialog(), ); - if (collectionName == null || collectionName.isEmpty || !context.mounted) { + if (collectionName == null || + collectionName.isEmpty || + !context.mounted) { return; } @@ -800,7 +809,9 @@ class _MediaContextMenuState extends State { break; } - appLogger.d('Creating collection "$collectionName" with type $collectionType'); + appLogger.d( + 'Creating collection "$collectionName" with type $collectionType', + ); final newCollectionId = await client.createCollection( sectionId: sectionId.toString(), title: collectionName, @@ -810,10 +821,14 @@ class _MediaContextMenuState extends State { if (context.mounted) { if (newCollectionId != null) { - appLogger.d('Successfully created collection with ID: $newCollectionId'); + appLogger.d( + 'Successfully created collection with ID: $newCollectionId', + ); // Now add the item to the newly created collection - appLogger.d('Adding item to new collection $newCollectionId with URI: $itemUri'); + appLogger.d( + 'Adding item to new collection $newCollectionId with URI: $itemUri', + ); final addSuccess = await client.addToCollection( collectionId: newCollectionId, uri: itemUri, @@ -828,15 +843,15 @@ class _MediaContextMenuState extends State { LibraryRefreshNotifier().notifyCollectionsChanged(); } else { appLogger.e('Failed to add item to new collection'); - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(t.collections.errorAddingToCollection))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.collections.errorAddingToCollection)), + ); } } else { appLogger.e('Failed to create collection - API returned null'); - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(t.collections.errorAddingToCollection))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.collections.errorAddingToCollection)), + ); } } } else { @@ -850,18 +865,18 @@ class _MediaContextMenuState extends State { if (context.mounted) { if (success) { appLogger.d('Successfully added item(s) to collection $result'); - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(t.collections.addedToCollection))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.collections.addedToCollection)), + ); // Trigger refresh of collections tab LibraryRefreshNotifier().notifyCollectionsChanged(); } else { appLogger.e( 'Failed to add item(s) to collection $result - API returned false', ); - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(t.collections.errorAddingToCollection))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.collections.errorAddingToCollection)), + ); } } } @@ -874,7 +889,9 @@ class _MediaContextMenuState extends State { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('${t.collections.errorAddingToCollection}: ${e.toString()}'), + content: Text( + '${t.collections.errorAddingToCollection}: ${e.toString()}', + ), duration: const Duration(seconds: 5), ), ); @@ -883,7 +900,10 @@ class _MediaContextMenuState extends State { } /// Handle remove from collection action - Future _handleRemoveFromCollection(BuildContext context, PlexMetadata metadata) async { + Future _handleRemoveFromCollection( + BuildContext context, + PlexMetadata metadata, + ) async { final client = context.client; if (client == null) return; @@ -928,9 +948,7 @@ class _MediaContextMenuState extends State { if (context.mounted) { if (success) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(t.collections.removedFromCollection), - ), + SnackBar(content: Text(t.collections.removedFromCollection)), ); // Trigger refresh of collections tab LibraryRefreshNotifier().notifyCollectionsChanged(); @@ -938,9 +956,7 @@ class _MediaContextMenuState extends State { widget.onListRefresh?.call(); } else { ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(t.collections.removeFromCollectionFailed), - ), + SnackBar(content: Text(t.collections.removeFromCollectionFailed)), ); } } @@ -959,7 +975,11 @@ class _MediaContextMenuState extends State { } /// Handle play action for collections and playlists - Future _handlePlay(BuildContext context, bool isCollection, bool isPlaylist) async { + Future _handlePlay( + BuildContext context, + bool isCollection, + bool isPlaylist, + ) async { final client = context.client; if (client == null) return; @@ -972,7 +992,11 @@ class _MediaContextMenuState extends State { } /// Handle shuffle action for collections and playlists - Future _handleShuffle(BuildContext context, bool isCollection, bool isPlaylist) async { + Future _handleShuffle( + BuildContext context, + bool isCollection, + bool isPlaylist, + ) async { final client = context.client; if (client == null) return; @@ -985,13 +1009,18 @@ class _MediaContextMenuState extends State { } /// Handle delete action for collections and playlists - Future _handleDelete(BuildContext context, bool isCollection, bool isPlaylist) async { + Future _handleDelete( + BuildContext context, + bool isCollection, + bool isPlaylist, + ) async { final client = context.client; if (client == null) return; final itemTitle = widget.item.title; - final itemTypeLabel = - isCollection ? t.collections.collection : t.playlists.playlist; + final itemTypeLabel = isCollection + ? t.collections.collection + : t.playlists.playlist; // Show confirmation dialog final confirmed = await showDialog( @@ -1241,7 +1270,8 @@ class _CollectionSelectionDialog extends StatelessWidget { /// Dialog to create a new collection class _CreateCollectionDialog extends StatefulWidget { @override - State<_CreateCollectionDialog> createState() => _CreateCollectionDialogState(); + State<_CreateCollectionDialog> createState() => + _CreateCollectionDialogState(); } class _CreateCollectionDialogState extends State<_CreateCollectionDialog> { diff --git a/lib/widgets/sort_bottom_sheet.dart b/lib/widgets/sort_bottom_sheet.dart index 0ff146d4..8c45770f 100644 --- a/lib/widgets/sort_bottom_sheet.dart +++ b/lib/widgets/sort_bottom_sheet.dart @@ -138,10 +138,7 @@ class _SortBottomSheetState extends State { : null, leading: Radio(value: sort, toggleable: false), onTap: () { - _handleSortChange( - sort, - sort.isDefaultDescending, - ); + _handleSortChange(sort, sort.isDefaultDescending); }, ); }, diff --git a/lib/widgets/video_controls/sheets/audio_sync_sheet.dart b/lib/widgets/video_controls/sheets/audio_sync_sheet.dart index 5d1af9a3..11ac2857 100644 --- a/lib/widgets/video_controls/sheets/audio_sync_sheet.dart +++ b/lib/widgets/video_controls/sheets/audio_sync_sheet.dart @@ -2,6 +2,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'; +import 'base_video_control_sheet.dart'; /// Bottom sheet for adjusting audio sync offset class AudioSyncSheet extends StatefulWidget { @@ -14,23 +15,12 @@ class AudioSyncSheet extends StatefulWidget { required this.initialOffset, }); - static BoxConstraints getBottomSheetConstraints(BuildContext context) { - final size = MediaQuery.of(context).size; - final isDesktop = size.width > 600; - - return BoxConstraints( - maxWidth: isDesktop ? 700 : double.infinity, - maxHeight: isDesktop ? 400 : size.height * 0.75, - minHeight: isDesktop ? 300 : size.height * 0.5, - ); - } - static void show(BuildContext context, Player player, int initialOffset) { showModalBottomSheet( context: context, backgroundColor: Colors.grey[900], isScrollControlled: true, - constraints: getBottomSheetConstraints(context), + constraints: BaseVideoControlSheet.getBottomSheetConstraints(context), builder: (context) => AudioSyncSheet(player: player, initialOffset: initialOffset), ); diff --git a/lib/widgets/video_controls/sheets/chapter_sheet.dart b/lib/widgets/video_controls/sheets/chapter_sheet.dart index 1fa83e61..5c615eb1 100644 --- a/lib/widgets/video_controls/sheets/chapter_sheet.dart +++ b/lib/widgets/video_controls/sheets/chapter_sheet.dart @@ -49,7 +49,8 @@ class ChapterSheet extends StatelessWidget { for (int i = 0; i < chapters.length; i++) { final chapter = chapters[i]; final startMs = chapter.startTimeOffset ?? 0; - final endMs = chapter.endTimeOffset ?? + final endMs = + chapter.endTimeOffset ?? (i < chapters.length - 1 ? chapters[i + 1].startTimeOffset ?? 0 : double.maxFinite.toInt()); @@ -94,22 +95,16 @@ class ChapterSheet extends StatelessWidget { ); } return Image.network( - client.getThumbnailUrl( - chapter.thumb, - ), + client.getThumbnailUrl(chapter.thumb), width: 60, height: 34, fit: BoxFit.cover, - errorBuilder: ( - context, - error, - stackTrace, - ) => + errorBuilder: (context, error, stackTrace) => const Icon( - Icons.image, - color: Colors.white54, - size: 34, - ), + Icons.image, + color: Colors.white54, + size: 34, + ), ); }, ), @@ -118,9 +113,7 @@ class ChapterSheet extends StatelessWidget { Positioned.fill( child: Container( decoration: BoxDecoration( - borderRadius: BorderRadius.circular( - 4, - ), + borderRadius: BorderRadius.circular(4), border: Border.all( color: Colors.blue, width: 2, @@ -150,10 +143,7 @@ class ChapterSheet extends StatelessWidget { ), ), trailing: isCurrentChapter - ? const Icon( - Icons.play_circle_filled, - color: Colors.blue, - ) + ? const Icon(Icons.play_circle_filled, color: Colors.blue) : null, onTap: () { player.seek(chapter.startTime); diff --git a/lib/widgets/video_controls/sheets/playback_speed_sheet.dart b/lib/widgets/video_controls/sheets/playback_speed_sheet.dart index ad11ea0a..ce727db3 100644 --- a/lib/widgets/video_controls/sheets/playback_speed_sheet.dart +++ b/lib/widgets/video_controls/sheets/playback_speed_sheet.dart @@ -36,8 +36,9 @@ class PlaybackSpeedSheet extends StatelessWidget { final isSelected = (currentRate - speed).abs() < 0.01; // Format speed label - final label = - speed == 1.0 ? 'Normal' : '${speed.toStringAsFixed(2)}x'; + final label = speed == 1.0 + ? 'Normal' + : '${speed.toStringAsFixed(2)}x'; return ListTile( title: Text( diff --git a/lib/widgets/video_controls/sheets/subtitle_track_sheet.dart b/lib/widgets/video_controls/sheets/subtitle_track_sheet.dart index faf01b97..90dd113f 100644 --- a/lib/widgets/video_controls/sheets/subtitle_track_sheet.dart +++ b/lib/widgets/video_controls/sheets/subtitle_track_sheet.dart @@ -67,15 +67,13 @@ class SubtitleTrackSheet extends StatelessWidget { title: Text( 'Off', style: TextStyle( - color: - isOffSelected ? Colors.blue : Colors.white, + color: isOffSelected + ? Colors.blue + : Colors.white, ), ), trailing: isOffSelected - ? const Icon( - Icons.check, - color: Colors.blue, - ) + ? const Icon(Icons.check, color: Colors.blue) : null, onTap: () { player.setSubtitleTrack(SubtitleTrack.no()); @@ -107,8 +105,7 @@ class SubtitleTrackSheet extends StatelessWidget { codecName = 'SRT'; } else if (codecName == 'DVD_SUBTITLE') { codecName = 'DVD'; - } else if (codecName == 'ASS' || - codecName == 'SSA') { + } else if (codecName == 'ASS' || codecName == 'SSA') { codecName = codecName; // Keep as-is } else if (codecName == 'WEBVTT') { codecName = 'VTT'; @@ -116,8 +113,9 @@ class SubtitleTrackSheet extends StatelessWidget { parts.add(codecName); } - final label = - parts.isEmpty ? 'Track $index' : parts.join(' · '); + final label = parts.isEmpty + ? 'Track $index' + : parts.join(' · '); return ListTile( title: Text( diff --git a/lib/widgets/video_controls/sheets/version_sheet.dart b/lib/widgets/video_controls/sheets/version_sheet.dart index 32540cce..70b45e9d 100644 --- a/lib/widgets/video_controls/sheets/version_sheet.dart +++ b/lib/widgets/video_controls/sheets/version_sheet.dart @@ -45,9 +45,7 @@ class VersionSheet extends StatelessWidget { return ListTile( title: Text( version.displayLabel, - style: TextStyle( - color: isSelected ? Colors.blue : Colors.white, - ), + style: TextStyle(color: isSelected ? Colors.blue : Colors.white), ), trailing: isSelected ? const Icon(Icons.check, color: Colors.blue) diff --git a/lib/widgets/video_controls/sheets/video_settings_sheet.dart b/lib/widgets/video_controls/sheets/video_settings_sheet.dart index 28ee1211..51ee84ed 100644 --- a/lib/widgets/video_controls/sheets/video_settings_sheet.dart +++ b/lib/widgets/video_controls/sheets/video_settings_sheet.dart @@ -6,6 +6,7 @@ import '../../../utils/platform_detector.dart'; import '../widgets/sync_offset_control.dart'; import '../widgets/sleep_timer_content.dart'; import '../../../i18n/strings.g.dart'; +import 'base_video_control_sheet.dart'; enum _SettingsView { menu, speed, sleep, audioSync, subtitleSync, audioDevice } @@ -39,21 +40,12 @@ class _SettingsMenuItem extends StatelessWidget { ); return ListTile( - leading: Icon( - icon, - color: isHighlighted ? Colors.amber : Colors.white70, - ), - title: Text( - title, - style: const TextStyle(color: Colors.white), - ), + leading: Icon(icon, color: isHighlighted ? Colors.amber : Colors.white70), + title: Text(title, style: const TextStyle(color: Colors.white)), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ - if (allowValueOverflow) - Flexible(child: valueWidget) - else - valueWidget, + if (allowValueOverflow) Flexible(child: valueWidget) else valueWidget, const SizedBox(width: 8), const Icon(Icons.chevron_right, color: Colors.white70), ], @@ -76,17 +68,6 @@ class VideoSettingsSheet extends StatefulWidget { required this.subtitleSyncOffset, }); - static BoxConstraints getBottomSheetConstraints(BuildContext context) { - final size = MediaQuery.of(context).size; - final isDesktop = size.width > 600; - - return BoxConstraints( - maxWidth: isDesktop ? 700 : double.infinity, - maxHeight: isDesktop ? 400 : size.height * 0.75, - minHeight: isDesktop ? 300 : size.height * 0.5, - ); - } - static Future show( BuildContext context, Player player, @@ -97,7 +78,7 @@ class VideoSettingsSheet extends StatefulWidget { context: context, backgroundColor: Colors.grey[900], isScrollControlled: true, - constraints: getBottomSheetConstraints(context), + constraints: BaseVideoControlSheet.getBottomSheetConstraints(context), builder: (context) => VideoSettingsSheet( player: player, audioSyncOffset: audioSyncOffset, @@ -194,7 +175,6 @@ class _VideoSettingsSheetState extends State { } } - Widget _buildHeader() { final sleepTimer = SleepTimerService(); final isIconActive = diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index 88029ca2..9b8ed853 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -1624,5 +1624,4 @@ class _PlexVideoControlsState extends State } } } - } From 1e12897c8a4e99ab20c56cd05e5b9a2cbc9f2ac1 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Mon, 17 Nov 2025 00:21:50 +0100 Subject: [PATCH 28/29] refactor: deduplicate --- lib/providers/playback_state_provider.dart | 82 +++++++--- lib/screens/hub_detail_screen.dart | 49 +----- lib/screens/search_screen.dart | 49 +----- lib/theme/mono_theme.dart | 34 ++-- lib/utils/duration_formatter.dart | 9 ++ lib/utils/grid_cross_axis_extent.dart | 51 ++++++ lib/widgets/media_card.dart | 150 ++++++------------ .../sheets/audio_sync_sheet.dart | 8 +- .../widgets/sync_offset_control.dart | 8 +- 9 files changed, 185 insertions(+), 255 deletions(-) create mode 100644 lib/utils/grid_cross_axis_extent.dart diff --git a/lib/providers/playback_state_provider.dart b/lib/providers/playback_state_provider.dart index fb6acca9..9f8877db 100644 --- a/lib/providers/playback_state_provider.dart +++ b/lib/providers/playback_state_provider.dart @@ -10,6 +10,19 @@ enum PlaybackMode { playQueue, // Play queue-based playback (playlists, collections, shuffle) } +/// Result of trying to locate the current queue index. +class _IndexLookupResult { + final int? index; + final bool attemptedLoad; + final bool loadFailed; + + const _IndexLookupResult({ + this.index, + this.attemptedLoad = false, + this.loadFailed = false, + }); +} + /// Manages playback state using Plex's play queue API. /// This provider is session-only and does not persist across app restarts. class PlaybackStateProvider with ChangeNotifier { @@ -158,6 +171,43 @@ class PlaybackStateProvider with ChangeNotifier { return false; } + Future<_IndexLookupResult> _getCurrentIndex({ + bool loadIfMissing = false, + }) async { + if (_playbackMode != PlaybackMode.playQueue || + _loadedItems.isEmpty || + _currentPlayQueueItemID == null) { + return const _IndexLookupResult(); + } + + var currentIndex = _loadedItems.indexWhere( + (item) => item.playQueueItemID == _currentPlayQueueItemID, + ); + + if (currentIndex != -1) { + return _IndexLookupResult(index: currentIndex); + } + + if (!loadIfMissing || _client == null || _playQueueId == null) { + return const _IndexLookupResult(); + } + + final loaded = await _ensureItemsLoaded(_currentPlayQueueItemID!); + if (!loaded) { + return const _IndexLookupResult(attemptedLoad: true, loadFailed: true); + } + + currentIndex = _loadedItems.indexWhere( + (item) => item.playQueueItemID == _currentPlayQueueItemID, + ); + + if (currentIndex == -1) { + return const _IndexLookupResult(attemptedLoad: true, loadFailed: true); + } + + return _IndexLookupResult(index: currentIndex, attemptedLoad: true); + } + /// Gets the next item in the playback queue. /// Returns null if queue is exhausted or current item is not in queue. /// [loopQueue] - If true, restart from beginning when queue is exhausted @@ -170,23 +220,14 @@ class PlaybackStateProvider with ChangeNotifier { return null; } - if (_loadedItems.isEmpty || _currentPlayQueueItemID == null) return null; - - // Find current item in loaded items - final currentIndex = _loadedItems.indexWhere( - (item) => item.playQueueItemID == _currentPlayQueueItemID, - ); - - if (currentIndex == -1) { - // Current item not in loaded window, try to load it - final loaded = await _ensureItemsLoaded(_currentPlayQueueItemID!); - if (!loaded) { + final indexResult = await _getCurrentIndex(loadIfMissing: true); + if (indexResult.index == null) { + if (indexResult.loadFailed) { clearShuffle(); - return null; } - // Try again after loading - return getNextEpisode(currentItemKey, loopQueue: loopQueue); + return null; } + final currentIndex = indexResult.index!; // Check if there's a next item in the loaded window if (currentIndex + 1 < _loadedItems.length) { @@ -240,17 +281,8 @@ class PlaybackStateProvider with ChangeNotifier { return null; } - if (_loadedItems.isEmpty || _currentPlayQueueItemID == null) return null; - - // Find current item in loaded items - final currentIndex = _loadedItems.indexWhere( - (item) => item.playQueueItemID == _currentPlayQueueItemID, - ); - - if (currentIndex == -1) { - // Current item not in loaded window - return null; - } + final currentIndex = (await _getCurrentIndex()).index; + if (currentIndex == null) return null; // Check if there's a previous item in the loaded window if (currentIndex > 0) { diff --git a/lib/screens/hub_detail_screen.dart b/lib/screens/hub_detail_screen.dart index 5e3fd5a4..993adc10 100644 --- a/lib/screens/hub_detail_screen.dart +++ b/lib/screens/hub_detail_screen.dart @@ -8,6 +8,7 @@ import '../providers/settings_provider.dart'; import '../services/settings_service.dart'; import '../utils/provider_extensions.dart'; import '../utils/app_logger.dart'; +import '../utils/grid_cross_axis_extent.dart'; import '../widgets/media_card.dart'; import '../widgets/desktop_app_bar.dart'; import '../widgets/sort_bottom_sheet.dart'; @@ -293,9 +294,10 @@ class _HubDetailScreenState extends State with Refreshable { padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), sliver: SliverGrid( gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: _getMaxCrossAxisExtent( + maxCrossAxisExtent: getMaxCrossAxisExtentWithPadding( context, context.watch().libraryDensity, + 16, ), childAspectRatio: 2 / 3.3, crossAxisSpacing: 0, @@ -313,49 +315,4 @@ class _HubDetailScreenState extends State with Refreshable { ), ); } - - double _getMaxCrossAxisExtent(BuildContext context, LibraryDensity density) { - final screenWidth = MediaQuery.of(context).size.width; - final padding = 16.0; // 8px left + 8px right - final availableWidth = screenWidth - padding; - - if (screenWidth >= 900) { - // Wide screens (desktop/large tablet landscape): Responsive division - double divisor; - double maxItemWidth; - - switch (density) { - case LibraryDensity.comfortable: - divisor = 6.5; - maxItemWidth = 280; - break; - case LibraryDensity.normal: - divisor = 8.0; - maxItemWidth = 200; - break; - case LibraryDensity.compact: - divisor = 10.0; - maxItemWidth = 160; - break; - } - - return (availableWidth / divisor).clamp(0, maxItemWidth); - } else if (screenWidth >= 600) { - // Medium screens (tablets): Fixed 4-5-6 items - int targetItemCount = switch (density) { - LibraryDensity.comfortable => 4, - LibraryDensity.normal => 5, - LibraryDensity.compact => 6, - }; - return availableWidth / targetItemCount; - } else { - // Small screens (phones): Fixed 2-3-4 items - int targetItemCount = switch (density) { - LibraryDensity.comfortable => 2, - LibraryDensity.normal => 3, - LibraryDensity.compact => 4, - }; - return availableWidth / targetItemCount; - } - } } diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 2764664e..7b181dbb 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -11,6 +11,7 @@ import '../models/plex_metadata.dart'; import '../providers/settings_provider.dart'; import '../services/settings_service.dart'; import '../utils/app_logger.dart'; +import '../utils/grid_cross_axis_extent.dart'; import '../utils/provider_extensions.dart'; import '../widgets/desktop_app_bar.dart'; import '../widgets/media_card.dart'; @@ -253,9 +254,10 @@ class _SearchScreenState extends State padding: const EdgeInsets.all(16), sliver: SliverGrid( gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: _getMaxCrossAxisExtent( + maxCrossAxisExtent: getMaxCrossAxisExtentWithPadding( context, settingsProvider.libraryDensity, + 32, ), childAspectRatio: 2 / 3.3, crossAxisSpacing: 8, @@ -279,49 +281,4 @@ class _SearchScreenState extends State ), ); } - - double _getMaxCrossAxisExtent(BuildContext context, LibraryDensity density) { - final screenWidth = MediaQuery.of(context).size.width; - final padding = 32.0; // 16px left + 16px right from SliverPadding - final availableWidth = screenWidth - padding; - - if (screenWidth >= 900) { - // Wide screens (desktop/large tablet landscape): Responsive division - double divisor; - double maxItemWidth; - - switch (density) { - case LibraryDensity.comfortable: - divisor = 6.5; - maxItemWidth = 280; - break; - case LibraryDensity.normal: - divisor = 8.0; - maxItemWidth = 200; - break; - case LibraryDensity.compact: - divisor = 10.0; - maxItemWidth = 160; - break; - } - - return (availableWidth / divisor).clamp(0, maxItemWidth); - } else if (screenWidth >= 600) { - // Medium screens (tablets): Fixed 4-5-6 items - int targetItemCount = switch (density) { - LibraryDensity.comfortable => 4, - LibraryDensity.normal => 5, - LibraryDensity.compact => 6, - }; - return availableWidth / targetItemCount; - } else { - // Small screens (phones): Fixed 2-3-4 items - int targetItemCount = switch (density) { - LibraryDensity.comfortable => 2, - LibraryDensity.normal => 3, - LibraryDensity.compact => 4, - }; - return availableWidth / targetItemCount; - } - } } diff --git a/lib/theme/mono_theme.dart b/lib/theme/mono_theme.dart index 78710145..02e46b7e 100644 --- a/lib/theme/mono_theme.dart +++ b/lib/theme/mono_theme.dart @@ -19,6 +19,16 @@ ThemeData monoTheme({required bool dark}) { textMuted: const Color(0x99111111), ); + final buttonStyle = ButtonStyle( + padding: const WidgetStatePropertyAll( + EdgeInsets.symmetric(horizontal: 18, vertical: 14), + ), + elevation: const WidgetStatePropertyAll(0), + backgroundColor: WidgetStatePropertyAll(c.text), + foregroundColor: WidgetStatePropertyAll(dark ? c.bg : Colors.white), + shape: const WidgetStatePropertyAll(StadiumBorder()), + ); + final base = ThemeData( useMaterial3: true, brightness: dark ? Brightness.dark : Brightness.light, @@ -103,28 +113,8 @@ ThemeData monoTheme({required bool dark}) { ), hintStyle: TextStyle(color: c.textMuted), ), - elevatedButtonTheme: ElevatedButtonThemeData( - style: ButtonStyle( - padding: const WidgetStatePropertyAll( - EdgeInsets.symmetric(horizontal: 18, vertical: 14), - ), - elevation: const WidgetStatePropertyAll(0), - backgroundColor: WidgetStatePropertyAll(c.text), - foregroundColor: WidgetStatePropertyAll(dark ? c.bg : Colors.white), - shape: const WidgetStatePropertyAll(StadiumBorder()), - ), - ), - filledButtonTheme: FilledButtonThemeData( - style: ButtonStyle( - padding: const WidgetStatePropertyAll( - EdgeInsets.symmetric(horizontal: 18, vertical: 14), - ), - elevation: const WidgetStatePropertyAll(0), - backgroundColor: WidgetStatePropertyAll(c.text), - foregroundColor: WidgetStatePropertyAll(dark ? c.bg : Colors.white), - shape: const WidgetStatePropertyAll(StadiumBorder()), - ), - ), + elevatedButtonTheme: ElevatedButtonThemeData(style: buttonStyle), + filledButtonTheme: FilledButtonThemeData(style: buttonStyle), dividerTheme: DividerThemeData(space: 0, thickness: 1, color: c.outline), listTileTheme: ListTileThemeData( dense: true, diff --git a/lib/utils/duration_formatter.dart b/lib/utils/duration_formatter.dart index afe5a8d8..698b612f 100644 --- a/lib/utils/duration_formatter.dart +++ b/lib/utils/duration_formatter.dart @@ -63,6 +63,15 @@ String formatDurationTimestamp(Duration duration) { } } +/// Formats a sync offset in milliseconds with sign indicator (e.g., "+150ms", "-250ms"). +/// This format is used for audio/subtitle synchronization adjustments. +/// +/// Used for: audio sync sheet, sync offset controls. +String formatSyncOffset(double offsetMs) { + final sign = offsetMs >= 0 ? '+' : ''; + return '$sign${offsetMs.round()}ms'; +} + /// Gets the duration package locale based on the current app locale. /// Falls back to English if the locale is not supported by the duration package. DurationLocale _getDurationLocale() { diff --git a/lib/utils/grid_cross_axis_extent.dart b/lib/utils/grid_cross_axis_extent.dart new file mode 100644 index 00000000..812d010f --- /dev/null +++ b/lib/utils/grid_cross_axis_extent.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import '../services/settings_service.dart'; + +/// Calculates the max cross-axis extent for grid items, accounting for outer padding. +double getMaxCrossAxisExtentWithPadding( + BuildContext context, + LibraryDensity density, + double horizontalPadding, +) { + final screenWidth = MediaQuery.of(context).size.width; + final availableWidth = screenWidth - horizontalPadding; + + if (screenWidth >= 900) { + // Wide screens (desktop/large tablet landscape): Responsive division + double divisor; + double maxItemWidth; + + switch (density) { + case LibraryDensity.comfortable: + divisor = 6.5; + maxItemWidth = 280; + break; + case LibraryDensity.normal: + divisor = 8.0; + maxItemWidth = 200; + break; + case LibraryDensity.compact: + divisor = 10.0; + maxItemWidth = 160; + break; + } + + return (availableWidth / divisor).clamp(0, maxItemWidth); + } else if (screenWidth >= 600) { + // Medium screens (tablets): Fixed 4-5-6 items + int targetItemCount = switch (density) { + LibraryDensity.comfortable => 4, + LibraryDensity.normal => 5, + LibraryDensity.compact => 6, + }; + return availableWidth / targetItemCount; + } else { + // Small screens (phones): Fixed 2-3-4 items + int targetItemCount = switch (density) { + LibraryDensity.comfortable => 2, + LibraryDensity.normal => 3, + LibraryDensity.compact => 4, + }; + return availableWidth / targetItemCount; + } +} diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index e9659b47..94b54cb2 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -323,62 +323,12 @@ class _MediaCardGrid extends StatelessWidget { children: [ ClipRRect( borderRadius: BorderRadius.circular(8), - child: _buildPosterImage(context), + child: _buildPosterImage(context, item), ), _PosterOverlay(item: item), ], ); } - - Widget _buildPosterImage(BuildContext context) { - String? posterUrl; - IconData fallbackIcon = Icons.movie; - - if (item is PlexPlaylist) { - posterUrl = (item as PlexPlaylist).displayImage; - fallbackIcon = Icons.playlist_play; - } else if (item is PlexMetadata) { - final useSeasonPoster = context.watch().useSeasonPoster; - posterUrl = (item as PlexMetadata).posterThumb( - useSeasonPoster: useSeasonPoster, - ); - } - - if (posterUrl != null) { - return Consumer( - builder: (context, clientProvider, child) { - final client = clientProvider.client; - if (client == null) { - return SkeletonLoader( - child: Center( - child: Icon(fallbackIcon, size: 40, color: Colors.white54), - ), - ); - } - - return CachedNetworkImage( - imageUrl: client.getThumbnailUrl(posterUrl!), - fit: BoxFit.cover, - width: double.infinity, - height: double.infinity, - filterQuality: FilterQuality.medium, - fadeInDuration: const Duration(milliseconds: 300), - placeholder: (context, url) => const SkeletonLoader(), - errorWidget: (context, url, error) => Container( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - child: Center(child: Icon(fallbackIcon, size: 40)), - ), - ); - }, - ); - } else { - return SkeletonLoader( - child: Center( - child: Icon(fallbackIcon, size: 40, color: Colors.white54), - ), - ); - } - } } /// List layout for media cards @@ -442,14 +392,8 @@ class _MediaCardList extends StatelessWidget { } double get _summaryFontSize { - switch (density) { - case LibraryDensity.compact: - return 11; - case LibraryDensity.normal: - return 12; - case LibraryDensity.comfortable: - return 13; - } + // Summary uses the same sizing as metadata text + return _metadataFontSize; } int get _summaryMaxLines { @@ -576,7 +520,7 @@ class _MediaCardList extends StatelessWidget { children: [ ClipRRect( borderRadius: BorderRadius.circular(8), - child: _buildPosterImage(context), + child: _buildPosterImage(context, item), ), _PosterOverlay(item: item), ], @@ -656,55 +600,53 @@ class _MediaCardList extends StatelessWidget { ), ); } +} - Widget _buildPosterImage(BuildContext context) { - String? posterUrl; - IconData fallbackIcon = Icons.movie; +Widget _buildPosterImage(BuildContext context, dynamic item) { + String? posterUrl; + IconData fallbackIcon = Icons.movie; - if (item is PlexPlaylist) { - posterUrl = (item as PlexPlaylist).displayImage; - fallbackIcon = Icons.playlist_play; - } else if (item is PlexMetadata) { - final useSeasonPoster = context.watch().useSeasonPoster; - posterUrl = (item as PlexMetadata).posterThumb( - useSeasonPoster: useSeasonPoster, - ); - } + if (item is PlexPlaylist) { + posterUrl = (item as PlexPlaylist).displayImage; + fallbackIcon = Icons.playlist_play; + } else if (item is PlexMetadata) { + final useSeasonPoster = context.watch().useSeasonPoster; + posterUrl = (item as PlexMetadata).posterThumb( + useSeasonPoster: useSeasonPoster, + ); + } - if (posterUrl != null) { - return Consumer( - builder: (context, clientProvider, child) { - final client = clientProvider.client; - if (client == null) { - return SkeletonLoader( - child: Center( - child: Icon(fallbackIcon, size: 40, color: Colors.white54), - ), - ); - } - - return CachedNetworkImage( - imageUrl: client.getThumbnailUrl(posterUrl!), - fit: BoxFit.cover, - width: double.infinity, - height: double.infinity, - filterQuality: FilterQuality.medium, - fadeInDuration: const Duration(milliseconds: 300), - placeholder: (context, url) => const SkeletonLoader(), - errorWidget: (context, url, error) => Container( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - child: Center(child: Icon(fallbackIcon, size: 40)), + if (posterUrl != null) { + return Consumer( + builder: (context, clientProvider, child) { + final client = clientProvider.client; + if (client == null) { + return SkeletonLoader( + child: Center( + child: Icon(fallbackIcon, size: 40, color: Colors.white54), ), ); - }, - ); - } else { - return SkeletonLoader( - child: Center( - child: Icon(fallbackIcon, size: 40, color: Colors.white54), - ), - ); - } + } + + return CachedNetworkImage( + imageUrl: client.getThumbnailUrl(posterUrl!), + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + filterQuality: FilterQuality.medium, + fadeInDuration: const Duration(milliseconds: 300), + placeholder: (context, url) => const SkeletonLoader(), + errorWidget: (context, url, error) => Container( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: Center(child: Icon(fallbackIcon, size: 40)), + ), + ); + }, + ); + } else { + return SkeletonLoader( + child: Center(child: Icon(fallbackIcon, size: 40, color: Colors.white54)), + ); } } diff --git a/lib/widgets/video_controls/sheets/audio_sync_sheet.dart b/lib/widgets/video_controls/sheets/audio_sync_sheet.dart index 11ac2857..6757124a 100644 --- a/lib/widgets/video_controls/sheets/audio_sync_sheet.dart +++ b/lib/widgets/video_controls/sheets/audio_sync_sheet.dart @@ -2,6 +2,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'; +import '../../../utils/duration_formatter.dart'; import 'base_video_control_sheet.dart'; /// Bottom sheet for adjusting audio sync offset @@ -61,11 +62,6 @@ class _AudioSyncSheetState extends State { _applyOffset(0); } - String _formatOffset(double offsetMs) { - final sign = offsetMs >= 0 ? '+' : ''; - return '$sign${offsetMs.round()}ms'; - } - @override Widget build(BuildContext context) { return SafeArea( @@ -104,7 +100,7 @@ class _AudioSyncSheetState extends State { children: [ // Current offset display Text( - _formatOffset(_currentOffset), + formatSyncOffset(_currentOffset), style: const TextStyle( color: Colors.white, fontSize: 48, diff --git a/lib/widgets/video_controls/widgets/sync_offset_control.dart b/lib/widgets/video_controls/widgets/sync_offset_control.dart index 16a8a7ea..37f4d989 100644 --- a/lib/widgets/video_controls/widgets/sync_offset_control.dart +++ b/lib/widgets/video_controls/widgets/sync_offset_control.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:media_kit/media_kit.dart'; import '../../../i18n/strings.g.dart'; +import '../../../utils/duration_formatter.dart'; /// Reusable widget for adjusting sync offsets (audio or subtitle) class SyncOffsetControl extends StatefulWidget { @@ -63,11 +64,6 @@ class _SyncOffsetControlState extends State { _applyOffset(0); } - String _formatOffset(double offsetMs) { - final sign = offsetMs >= 0 ? '+' : ''; - return '$sign${offsetMs.round()}ms'; - } - String _getDescriptionText() { if (_currentOffset > 0) { return t.videoControls.playsLater(label: widget.labelText); @@ -87,7 +83,7 @@ class _SyncOffsetControlState extends State { children: [ // Current offset display Text( - _formatOffset(_currentOffset), + formatSyncOffset(_currentOffset), style: const TextStyle( color: Colors.white, fontSize: 48, From 3030bac32b5b1c5a14c9dae8c74c62ab3b8e1352 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Mon, 17 Nov 2025 00:29:12 +0100 Subject: [PATCH 29/29] refactor: fix warnings --- lib/constants/layout_constants.dart | 1 - .../base_media_list_detail_screen.dart | 15 +- lib/screens/hub_detail_screen.dart | 1 - .../library_tabs/library_browse_tab.dart | 4 + lib/screens/playlist_detail_screen.dart | 2 + lib/screens/playlists_screen.dart | 322 ------------------ .../collection_playlist_play_helper.dart | 4 + lib/utils/shuffle_play_helper.dart | 1 + lib/widgets/empty_state_widget.dart | 8 +- lib/widgets/folder_tree_item.dart | 6 +- lib/widgets/media_card.dart | 6 +- lib/widgets/media_context_menu.dart | 12 + 12 files changed, 38 insertions(+), 344 deletions(-) delete mode 100644 lib/screens/playlists_screen.dart diff --git a/lib/constants/layout_constants.dart b/lib/constants/layout_constants.dart index 68c62510..3420d2cd 100644 --- a/lib/constants/layout_constants.dart +++ b/lib/constants/layout_constants.dart @@ -1,5 +1,4 @@ /// Layout and sizing constants used throughout the application - /// Screen width breakpoints for responsive design class ScreenBreakpoints { /// Breakpoint for tablet devices (600px) diff --git a/lib/screens/base_media_list_detail_screen.dart b/lib/screens/base_media_list_detail_screen.dart index 57d693c7..7ae55205 100644 --- a/lib/screens/base_media_list_detail_screen.dart +++ b/lib/screens/base_media_list_detail_screen.dart @@ -12,18 +12,9 @@ abstract class BaseMediaListDetailScreen extends State with Refreshable, ItemUpdatable { // State properties - concrete implementations to avoid duplication - List _items = []; - bool _isLoading = false; - String? _errorMessage; - - List get items => _items; - set items(List value) => _items = value; - - bool get isLoading => _isLoading; - set isLoading(bool value) => _isLoading = value; - - String? get errorMessage => _errorMessage; - set errorMessage(String? value) => _errorMessage = value; + List items = []; + bool isLoading = false; + String? errorMessage; @override PlexClient get client => context.clientSafe; diff --git a/lib/screens/hub_detail_screen.dart b/lib/screens/hub_detail_screen.dart index 993adc10..e36ac821 100644 --- a/lib/screens/hub_detail_screen.dart +++ b/lib/screens/hub_detail_screen.dart @@ -5,7 +5,6 @@ import '../models/plex_hub.dart'; import '../models/plex_metadata.dart'; import '../models/plex_sort.dart'; import '../providers/settings_provider.dart'; -import '../services/settings_service.dart'; import '../utils/provider_extensions.dart'; import '../utils/app_logger.dart'; import '../utils/grid_cross_axis_extent.dart'; diff --git a/lib/screens/library_tabs/library_browse_tab.dart b/lib/screens/library_tabs/library_browse_tab.dart index 29dca645..c1de6bd8 100644 --- a/lib/screens/library_tabs/library_browse_tab.dart +++ b/lib/screens/library_tabs/library_browse_tab.dart @@ -315,7 +315,9 @@ class _LibraryBrowseTabState extends State return RadioListTile( title: Text(_getGroupingLabel(grouping)), value: grouping, + // ignore: deprecated_member_use groupValue: _selectedGrouping, + // ignore: deprecated_member_use onChanged: (value) async { if (value != null) { setState(() { @@ -325,6 +327,8 @@ class _LibraryBrowseTabState extends State final storage = await StorageService.getInstance(); await storage.saveLibraryGrouping(widget.library.key, value); + if (!mounted) return; + Navigator.pop(context); _loadItems(); } diff --git a/lib/screens/playlist_detail_screen.dart b/lib/screens/playlist_detail_screen.dart index b5001d8e..c761030f 100644 --- a/lib/screens/playlist_detail_screen.dart +++ b/lib/screens/playlist_detail_screen.dart @@ -245,6 +245,8 @@ class _PlaylistDetailScreenState return; } + if (!mounted) return; + // Set play queue in provider final playbackState = context.read(); playbackState.setClient(client); diff --git a/lib/screens/playlists_screen.dart b/lib/screens/playlists_screen.dart deleted file mode 100644 index 26fc1fd8..00000000 --- a/lib/screens/playlists_screen.dart +++ /dev/null @@ -1,322 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import '../client/plex_client.dart'; -import '../models/plex_playlist.dart'; -import '../providers/settings_provider.dart'; -import '../utils/provider_extensions.dart'; -import '../utils/app_logger.dart'; -import '../utils/grid_size_calculator.dart'; -import '../utils/dialogs.dart'; -import '../widgets/desktop_app_bar.dart'; -import '../mixins/refreshable.dart'; -import '../i18n/strings.g.dart'; -import 'playlist_detail_screen.dart'; - -/// Screen to display all video playlists -class PlaylistsScreen extends StatefulWidget { - const PlaylistsScreen({super.key}); - - @override - State createState() => _PlaylistsScreenState(); -} - -class _PlaylistsScreenState extends State with Refreshable { - PlexClient get client => context.clientSafe; - - List _playlists = []; - bool _isLoading = false; - String? _errorMessage; - bool? _filterSmart; - - @override - void initState() { - super.initState(); - _loadPlaylists(); - } - - Future _loadPlaylists() async { - setState(() { - _isLoading = true; - _errorMessage = null; - }); - - try { - final clientProvider = context.plexClient; - final client = clientProvider.client; - if (client == null) { - throw Exception('No client available'); - } - - final playlists = await client.getPlaylists( - playlistType: 'video', - smart: _filterSmart, - ); - - setState(() { - _playlists = playlists; - _isLoading = false; - }); - - appLogger.d('Loaded ${playlists.length} playlists'); - } catch (e) { - appLogger.e('Failed to load playlists', error: e); - setState(() { - _errorMessage = 'Failed to load playlists: ${e.toString()}'; - _isLoading = false; - }); - } - } - - void _toggleSmartFilter() { - setState(() { - if (_filterSmart == null) { - _filterSmart = true; // Show only smart - } else if (_filterSmart == true) { - _filterSmart = false; // Show only regular - } else { - _filterSmart = null; // Show all - } - }); - _loadPlaylists(); - } - - String _getFilterLabel() { - if (_filterSmart == null) return 'All'; - if (_filterSmart == true) return 'Smart'; - return 'Regular'; - } - - @override - void refresh() { - _loadPlaylists(); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - body: CustomScrollView( - slivers: [ - CustomAppBar( - title: Text(t.playlists.title), - pinned: true, - actions: [ - TextButton.icon( - icon: const Icon(Icons.filter_list), - label: Text(_getFilterLabel()), - onPressed: _toggleSmartFilter, - ), - ], - ), - if (_errorMessage != null) - SliverFillRemaining( - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - Icons.error_outline, - size: 48, - color: Colors.red, - ), - const SizedBox(height: 16), - Text(_errorMessage!), - const SizedBox(height: 16), - ElevatedButton( - onPressed: _loadPlaylists, - child: Text(t.common.retry), - ), - ], - ), - ), - ) - else if (_playlists.isEmpty && _isLoading) - const SliverFillRemaining( - child: Center(child: CircularProgressIndicator()), - ) - else if (_playlists.isEmpty) - SliverFillRemaining( - child: Center(child: Text(t.playlists.noPlaylists)), - ) - else - SliverPadding( - padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), - sliver: SliverGrid( - gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent( - context, - context.watch().libraryDensity, - ), - childAspectRatio: 2 / 3.3, - crossAxisSpacing: 0, - mainAxisSpacing: 0, - ), - delegate: SliverChildBuilderDelegate((context, index) { - return _PlaylistCard( - playlist: _playlists[index], - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => - PlaylistDetailScreen(playlist: _playlists[index]), - ), - ).then((_) => _loadPlaylists()); // Refresh on return - }, - onDeleted: _loadPlaylists, - ); - }, childCount: _playlists.length), - ), - ), - ], - ), - ); - } -} - -/// Widget to display a single playlist card -class _PlaylistCard extends StatelessWidget { - final PlexPlaylist playlist; - final VoidCallback onTap; - final VoidCallback onDeleted; - - const _PlaylistCard({ - required this.playlist, - required this.onTap, - required this.onDeleted, - }); - - Future _showDeleteDialog(BuildContext context) async { - final confirmed = await showDeleteConfirmation( - context, - title: t.playlists.deleteConfirm, - message: t.playlists.deleteMessage(name: playlist.title), - ); - - if (confirmed == true && context.mounted) { - final client = context.clientSafe; - final success = await client.deletePlaylist(playlist.ratingKey); - - if (context.mounted) { - if (success) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(t.playlists.deleted))); - onDeleted(); - } else { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(t.playlists.errorDeleting))); - } - } - } - } - - @override - Widget build(BuildContext context) { - final client = context.clientSafe; - final imageUrl = playlist.displayImage != null - ? client.getThumbnailUrl(playlist.displayImage!) - : null; - - return Card( - clipBehavior: Clip.antiAlias, - margin: const EdgeInsets.all(4), - child: InkWell( - onTap: onTap, - onLongPress: () => _showDeleteDialog(context), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Playlist image - Expanded( - child: Stack( - fit: StackFit.expand, - children: [ - if (imageUrl != null) - Image.network( - imageUrl, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - return _buildPlaceholder(); - }, - ) - else - _buildPlaceholder(), - // Smart playlist indicator - if (playlist.smart) - Positioned( - top: 4, - right: 4, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: Colors.blue.withOpacity(0.9), - borderRadius: BorderRadius.circular(4), - ), - child: const Icon( - Icons.auto_awesome, - size: 12, - color: Colors.white, - ), - ), - ), - ], - ), - ), - // Playlist info - Padding( - padding: const EdgeInsets.all(8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - playlist.title, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontWeight: FontWeight.w500, - fontSize: 13, - ), - ), - const SizedBox(height: 4), - Row( - children: [ - Icon( - Icons.playlist_play, - size: 14, - color: Colors.grey[600], - ), - const SizedBox(width: 4), - Text( - playlist.leafCount != null && playlist.leafCount! > 0 - ? (playlist.leafCount == 1 - ? t.playlists.oneItem - : t.playlists.itemCount( - count: playlist.leafCount!, - )) - : t.playlists.emptyPlaylist, - style: TextStyle(fontSize: 12, color: Colors.grey[600]), - ), - ], - ), - ], - ), - ), - ], - ), - ), - ); - } - - Widget _buildPlaceholder() { - return Container( - color: Colors.grey[850], - child: const Center( - child: Icon(Icons.playlist_play, size: 48, color: Colors.grey), - ), - ); - } -} diff --git a/lib/utils/collection_playlist_play_helper.dart b/lib/utils/collection_playlist_play_helper.dart index 61a98cf4..6203f34b 100644 --- a/lib/utils/collection_playlist_play_helper.dart +++ b/lib/utils/collection_playlist_play_helper.dart @@ -64,6 +64,8 @@ Future playCollectionOrPlaylist({ playbackState.setClient(client); await playbackState.setPlaybackFromPlayQueue(fetchedQueue, ratingKey); + if (!context.mounted) return; + // Navigate to first item await navigateToVideoPlayer( context, @@ -91,6 +93,8 @@ Future playCollectionOrPlaylist({ playbackState.setClient(client); await playbackState.setPlaybackFromPlayQueue(playQueue, ratingKey); + if (!context.mounted) return; + // Navigate to first item await navigateToVideoPlayer(context, metadata: playQueue.items!.first); } catch (e) { diff --git a/lib/utils/shuffle_play_helper.dart b/lib/utils/shuffle_play_helper.dart index 58abed1c..a14ab81e 100644 --- a/lib/utils/shuffle_play_helper.dart +++ b/lib/utils/shuffle_play_helper.dart @@ -90,6 +90,7 @@ Future handleShufflePlay( episodes.shuffle(); // Store shuffle queue in provider + // ignore: deprecated_member_use_from_same_package playbackState.setShuffleQueue(episodes, metadata.ratingKey); // Navigate to first episode diff --git a/lib/widgets/empty_state_widget.dart b/lib/widgets/empty_state_widget.dart index 72535100..d8f83a8e 100644 --- a/lib/widgets/empty_state_widget.dart +++ b/lib/widgets/empty_state_widget.dart @@ -34,7 +34,9 @@ class EmptyStateWidget extends StatelessWidget { Icon( icon, size: 64, - color: Theme.of(context).colorScheme.onSurface.withOpacity(0.4), + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.4), ), const SizedBox(height: 16), ], @@ -42,7 +44,9 @@ class EmptyStateWidget extends StatelessWidget { message, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6), + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.6), ), ), if (onAction != null && actionLabel != null) ...[ diff --git a/lib/widgets/folder_tree_item.dart b/lib/widgets/folder_tree_item.dart index 4c765325..9b5d0c12 100644 --- a/lib/widgets/folder_tree_item.dart +++ b/lib/widgets/folder_tree_item.dart @@ -89,7 +89,9 @@ class FolderTreeItem extends StatelessWidget { size: 20, color: isFolder ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.onSurface.withOpacity(0.7), + : Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.7), ), const SizedBox(width: 12), @@ -115,7 +117,7 @@ class FolderTreeItem extends StatelessWidget { fontSize: 12, color: Theme.of( context, - ).colorScheme.onSurface.withOpacity(0.6), + ).colorScheme.onSurface.withValues(alpha: 0.6), ), ), ], diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index 94b54cb2..e420a0ce 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -607,13 +607,11 @@ Widget _buildPosterImage(BuildContext context, dynamic item) { IconData fallbackIcon = Icons.movie; if (item is PlexPlaylist) { - posterUrl = (item as PlexPlaylist).displayImage; + posterUrl = item.displayImage; fallbackIcon = Icons.playlist_play; } else if (item is PlexMetadata) { final useSeasonPoster = context.watch().useSeasonPoster; - posterUrl = (item as PlexMetadata).posterThumb( - useSeasonPoster: useSeasonPoster, - ); + posterUrl = item.posterThumb(useSeasonPoster: useSeasonPoster); } if (posterUrl != null) { diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index 87a2013a..babdade0 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -608,6 +608,8 @@ class _MediaContextMenuState extends State { final itemUri = await client.buildMetadataUri(metadata.ratingKey); appLogger.d('Built URI for $itemType: $itemUri'); + if (!context.mounted) return; + if (result == '_create_new') { // Create new playlist flow final playlistName = await showDialog( @@ -628,6 +630,8 @@ class _MediaContextMenuState extends State { uri: itemUri, ); + if (!context.mounted) return; + if (context.mounted) { if (newPlaylist != null) { appLogger.d('Successfully created playlist: ${newPlaylist.title}'); @@ -651,6 +655,8 @@ class _MediaContextMenuState extends State { uri: itemUri, ); + if (!context.mounted) return; + if (context.mounted) { if (success) { appLogger.d('Successfully added item(s) to playlist $result'); @@ -819,6 +825,8 @@ class _MediaContextMenuState extends State { type: collectionType, ); + if (!context.mounted) return; + if (context.mounted) { if (newCollectionId != null) { appLogger.d( @@ -834,6 +842,8 @@ class _MediaContextMenuState extends State { uri: itemUri, ); + if (!context.mounted) return; + if (addSuccess) { appLogger.d('Successfully added item to new collection'); ScaffoldMessenger.of( @@ -862,6 +872,8 @@ class _MediaContextMenuState extends State { uri: itemUri, ); + if (!context.mounted) return; + if (context.mounted) { if (success) { appLogger.d('Successfully added item(s) to collection $result');