Merge pull request #80 from Tazi0/feature/skip-into-and-credit
feat: Skip intro and credit
This commit is contained in:
@@ -1,14 +1,16 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../config/plex_config.dart';
|
||||
import '../models/plex_library.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../models/plex_media_info.dart';
|
||||
import '../models/plex_file_info.dart';
|
||||
import '../models/plex_filter.dart';
|
||||
import '../models/plex_sort.dart';
|
||||
import '../models/plex_media_version.dart';
|
||||
import '../models/plex_hub.dart';
|
||||
import '../models/plex_library.dart';
|
||||
import '../models/plex_media_info.dart';
|
||||
import '../models/plex_media_version.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../models/plex_sort.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
|
||||
/// Result of testing a connection, including success status and latency
|
||||
@@ -496,6 +498,29 @@ class PlexClient {
|
||||
return [];
|
||||
}
|
||||
|
||||
Future<List<PlexMarker>> getMarkers(String ratingKey) async {
|
||||
final response = await _dio.get(
|
||||
'/library/metadata/$ratingKey',
|
||||
queryParameters: {'includeMarkers': 1},
|
||||
);
|
||||
|
||||
final metadataJson = _getFirstMetadataJson(response);
|
||||
|
||||
if (metadataJson != null && metadataJson['Marker'] != null) {
|
||||
final markerList = metadataJson['Marker'] as List;
|
||||
return markerList.map((marker) {
|
||||
return PlexMarker(
|
||||
id: marker['id'] as int,
|
||||
type: marker['type'] as String,
|
||||
startTimeOffset: marker['startTimeOffset'] as int,
|
||||
endTimeOffset: marker['endTimeOffset'] as int,
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Get detailed media info including chapters and tracks
|
||||
/// [mediaIndex] specifies which Media item to use (defaults to 0 - first version)
|
||||
Future<PlexMediaInfo?> getMediaInfo(
|
||||
|
||||
@@ -146,3 +146,28 @@ class PlexChapter {
|
||||
Duration? get endTime =>
|
||||
endTimeOffset != null ? Duration(milliseconds: endTimeOffset!) : null;
|
||||
}
|
||||
|
||||
class PlexMarker {
|
||||
final int id;
|
||||
final String type;
|
||||
final int startTimeOffset;
|
||||
final int endTimeOffset;
|
||||
|
||||
PlexMarker({
|
||||
required this.id,
|
||||
required this.type,
|
||||
required this.startTimeOffset,
|
||||
required this.endTimeOffset,
|
||||
});
|
||||
|
||||
Duration get startTime => Duration(milliseconds: startTimeOffset);
|
||||
Duration get endTime => Duration(milliseconds: endTimeOffset);
|
||||
|
||||
bool get isIntro => type == 'intro';
|
||||
bool get isCredits => type == 'credits';
|
||||
|
||||
bool containsPosition(Duration position) {
|
||||
final posMs = position.inMilliseconds;
|
||||
return posMs >= startTimeOffset && posMs <= endTimeOffset;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:media_kit_video/media_kit_video.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:os_media_controls/os_media_controls.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../models/plex_media_version.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../models/plex_user_profile.dart';
|
||||
import '../providers/plex_client_provider.dart';
|
||||
import '../providers/playback_state_provider.dart';
|
||||
import '../providers/plex_client_provider.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
import '../widgets/video_controls/video_controls.dart';
|
||||
import '../utils/language_codes.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/language_codes.dart';
|
||||
import '../utils/orientation_helper.dart';
|
||||
import '../utils/video_player_navigation.dart';
|
||||
import '../utils/platform_detector.dart';
|
||||
import '../models/plex_media_version.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
import '../utils/video_player_navigation.dart';
|
||||
import '../widgets/video_controls/video_controls.dart';
|
||||
|
||||
class VideoPlayerScreen extends StatefulWidget {
|
||||
final PlexMetadata metadata;
|
||||
@@ -240,7 +242,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Future<void> _loadAdjacentEpisodes() async {
|
||||
if (widget.metadata.type.toLowerCase() != 'episode') {
|
||||
return;
|
||||
@@ -269,7 +270,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
widget.metadata.ratingKey,
|
||||
loopQueue: loopQueue,
|
||||
);
|
||||
previous = playbackState.getPreviousEpisode(widget.metadata.ratingKey);
|
||||
previous = playbackState.getPreviousEpisode(
|
||||
widget.metadata.ratingKey,
|
||||
);
|
||||
} else {
|
||||
// Use chronological order even in shuffle mode
|
||||
next = await client.findAdjacentEpisode(widget.metadata, 1);
|
||||
@@ -1204,7 +1207,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
// Get artwork URL
|
||||
String? artworkUrl;
|
||||
if (client == null) {
|
||||
appLogger.w('Cannot get artwork URL for media controls: Plex client is null');
|
||||
appLogger.w(
|
||||
'Cannot get artwork URL for media controls: Plex client is null',
|
||||
);
|
||||
} else {
|
||||
final thumbUrl = metadata.type.toLowerCase() == 'episode'
|
||||
? metadata.grandparentThumb ?? metadata.thumb
|
||||
@@ -1235,15 +1240,17 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
await OsMediaControls.setMetadata(MediaMetadata(
|
||||
title: title,
|
||||
artist: artist,
|
||||
album: album,
|
||||
duration: metadata.duration != null
|
||||
? Duration(milliseconds: metadata.duration!)
|
||||
: null,
|
||||
artworkUrl: artworkUrl,
|
||||
));
|
||||
await OsMediaControls.setMetadata(
|
||||
MediaMetadata(
|
||||
title: title,
|
||||
artist: artist,
|
||||
album: album,
|
||||
duration: metadata.duration != null
|
||||
? Duration(milliseconds: metadata.duration!)
|
||||
: null,
|
||||
artworkUrl: artworkUrl,
|
||||
),
|
||||
);
|
||||
|
||||
// Set initial playback state
|
||||
_updateMediaControlsPlaybackState();
|
||||
@@ -1252,11 +1259,15 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
void _updateMediaControlsPlaybackState() {
|
||||
if (player == null) return;
|
||||
|
||||
OsMediaControls.setPlaybackState(MediaPlaybackState(
|
||||
state: player!.state.playing ? PlaybackState.playing : PlaybackState.paused,
|
||||
position: player!.state.position,
|
||||
speed: player!.state.rate,
|
||||
));
|
||||
OsMediaControls.setPlaybackState(
|
||||
MediaPlaybackState(
|
||||
state: player!.state.playing
|
||||
? PlaybackState.playing
|
||||
: PlaybackState.paused,
|
||||
position: player!.state.position,
|
||||
speed: player!.state.rate,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _updateMediaControlsPosition() {
|
||||
@@ -1264,11 +1275,13 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
|
||||
// Only update if playing to avoid excessive updates
|
||||
if (player!.state.playing) {
|
||||
OsMediaControls.setPlaybackState(MediaPlaybackState(
|
||||
state: PlaybackState.playing,
|
||||
position: player!.state.position,
|
||||
speed: player!.state.rate,
|
||||
));
|
||||
OsMediaControls.setPlaybackState(
|
||||
MediaPlaybackState(
|
||||
state: PlaybackState.playing,
|
||||
position: player!.state.position,
|
||||
speed: player!.state.rate,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart' show SystemChrome, DeviceOrientation;
|
||||
import 'package:macos_window_utils/macos_window_utils.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
import 'package:macos_window_utils/macos_window_utils.dart';
|
||||
import '../../models/plex_metadata.dart';
|
||||
|
||||
import '../../models/plex_media_info.dart';
|
||||
import '../../models/plex_media_version.dart';
|
||||
import '../../models/plex_metadata.dart';
|
||||
import '../../screens/video_player_screen.dart';
|
||||
import '../../services/fullscreen_state_manager.dart';
|
||||
import '../../services/keyboard_shortcuts_service.dart';
|
||||
import '../../services/settings_service.dart';
|
||||
@@ -15,7 +18,6 @@ import '../../services/sleep_timer_service.dart';
|
||||
import '../../utils/desktop_window_padding.dart';
|
||||
import '../../utils/platform_detector.dart';
|
||||
import '../../utils/provider_extensions.dart';
|
||||
import '../../screens/video_player_screen.dart';
|
||||
import '../app_bar_back_button.dart';
|
||||
import 'painters/chapter_marker_painter.dart';
|
||||
import 'sheets/audio_track_sheet.dart';
|
||||
@@ -95,15 +97,21 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
// Seek throttle state
|
||||
Timer? _seekThrottleTimer;
|
||||
Duration? _pendingSeekPosition;
|
||||
// Current marker state
|
||||
PlexMarker? _currentMarker;
|
||||
List<PlexMarker> _markers = [];
|
||||
bool _markersLoaded = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focusNode = FocusNode();
|
||||
_loadChapters();
|
||||
_loadMarkers();
|
||||
_loadSeekTimes();
|
||||
_startHideTimer();
|
||||
_initKeyboardService();
|
||||
_listenToPosition();
|
||||
// Add lifecycle observer to reload settings when app resumes
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
// Add window listener for tracking fullscreen state (for button icon)
|
||||
@@ -116,6 +124,36 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
_keyboardService = await KeyboardShortcutsService.getInstance();
|
||||
}
|
||||
|
||||
void _listenToPosition() {
|
||||
widget.player.stream.position.listen((position) {
|
||||
if (_markers.isEmpty || !_markersLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
PlexMarker? foundMarker;
|
||||
for (final marker in _markers) {
|
||||
if (marker.containsPosition(position)) {
|
||||
foundMarker = marker;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (foundMarker != _currentMarker) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_currentMarker = foundMarker;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _skipMarker() {
|
||||
if (_currentMarker != null) {
|
||||
widget.player.seek(_currentMarker!.endTime);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadSeekTimes() async {
|
||||
final settingsService = await SettingsService.getInstance();
|
||||
if (mounted) {
|
||||
@@ -309,6 +347,21 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadMarkers() async {
|
||||
final clientProvider = context.plexClient;
|
||||
final client = clientProvider.client;
|
||||
if (client == null) return;
|
||||
|
||||
final markers = await client.getMarkers(widget.metadata.ratingKey);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_markers = markers;
|
||||
_markersLoaded = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
bool _hasMultipleAudioTracks(Tracks? tracks) {
|
||||
if (tracks == null) return false;
|
||||
final audioTracks = tracks.audio
|
||||
@@ -855,12 +908,74 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
),
|
||||
),
|
||||
),
|
||||
// Skip intro/credits button
|
||||
if (_currentMarker != null)
|
||||
Positioned(
|
||||
right: 24,
|
||||
bottom: isMobile ? 80 : 115,
|
||||
child: AnimatedOpacity(
|
||||
opacity: 1.0,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: _buildSkipMarkerButton(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSkipMarkerButton() {
|
||||
final isCredits = _currentMarker!.isCredits;
|
||||
final hasNextEpisode = widget.onNext != null;
|
||||
|
||||
// Show "Next Episode" for credits when next episode is available
|
||||
final bool showNextEpisode = isCredits && hasNextEpisode;
|
||||
final String buttonText = showNextEpisode
|
||||
? 'Next Episode'
|
||||
: (isCredits ? 'Skip Credits' : 'Skip Intro');
|
||||
final IconData buttonIcon = showNextEpisode
|
||||
? Icons.skip_next
|
||||
: Icons.fast_forward;
|
||||
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: showNextEpisode ? widget.onNext : _skipMarker,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.9),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.3),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
buttonText,
|
||||
style: const TextStyle(
|
||||
color: Colors.black,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(buttonIcon, color: Colors.black, size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMobileLayout() {
|
||||
return Column(
|
||||
children: [
|
||||
|
||||
Reference in New Issue
Block a user