diff --git a/lib/controllers/playerController.dart b/lib/controllers/playerController.dart index 2aa96a5f..befa291b 100644 --- a/lib/controllers/playerController.dart +++ b/lib/controllers/playerController.dart @@ -14,6 +14,7 @@ import '../models/db/settings.dart'; import '../models/pair.dart'; import '../../models/db/progress.dart' as dbProgress; import '../models/sponsorSegment.dart'; +import '../models/sponsorSegmentTypes.dart'; import '../models/video.dart'; import 'miniPayerController.dart'; @@ -21,7 +22,6 @@ class PlayerController extends GetxController { static PlayerController? to() => safeGet(); final log = Logger('VideoPlayer'); - bool useSponsorBlock = db.getSettings(USE_SPONSORBLOCK)?.value == 'true'; List> sponsorSegments = List.of([]); Pair nextSegment = Pair(0, 0); BetterPlayerController? videoController; @@ -53,7 +53,6 @@ class PlayerController extends GetxController { @override onReady() async { - await setSponsorBlock(); playVideo(); } @@ -88,7 +87,7 @@ class PlayerController extends GetxController { saveProgress(currentPosition); log.info("video event"); - if (useSponsorBlock && sponsorSegments.isNotEmpty) { + if (sponsorSegments.isNotEmpty) { double positionInMs = currentPosition * 1000; Pair nextSegment = sponsorSegments.firstWhere((e) => e.first <= positionInMs && positionInMs <= e.last, orElse: () => Pair(-1, -1)); if (nextSegment.first != -1) { @@ -160,6 +159,8 @@ class PlayerController extends GetxController { } playVideo() { + // we get segments if there are any, no need to wait. + setSponsorBlock(); double progress = db.getVideoProgress(video.videoId); Duration? startAt; if (progress > 0 && progress < 0.90) { @@ -231,8 +232,10 @@ class PlayerController extends GetxController { } setSponsorBlock() async { - if (useSponsorBlock) { - List sponsorSegments = await service.getSponsorSegments(video.videoId); + List types = SponsorSegmentType.values.where((e) => db.getSettings(e.settingsName())?.value == 'true').toList(); + + if (types.isNotEmpty) { + List sponsorSegments = await service.getSponsorSegments(video.videoId, types); List> segments = List.from(sponsorSegments.map((e) { Duration start = Duration(seconds: e.segment[0].floor()); Duration end = Duration(seconds: e.segment[1].floor()); @@ -241,7 +244,9 @@ class PlayerController extends GetxController { })); this.sponsorSegments = segments; - update(); + log.info('we found ${segments.length} segments to skip'); + } else { + sponsorSegments = []; } } } diff --git a/lib/controllers/sponsorBlockSettingsController.dart b/lib/controllers/sponsorBlockSettingsController.dart new file mode 100644 index 00000000..df6e0a53 --- /dev/null +++ b/lib/controllers/sponsorBlockSettingsController.dart @@ -0,0 +1,14 @@ +import 'package:get/get.dart'; +import 'package:invidious/database.dart'; +import 'package:invidious/globals.dart'; +import 'package:invidious/models/db/settings.dart'; +import 'package:invidious/models/sponsorSegmentTypes.dart'; + +class SponsorBlockSettingsController extends GetxController { + bool value(SponsorSegmentType t) => db.getSettings(t.settingsName())?.value == 'true'; + + setValue(SponsorSegmentType t, bool value) { + db.saveSetting(SettingsValue(t.settingsName(), value.toString())); + update(); + } +} diff --git a/lib/database.dart b/lib/database.dart index 2bb384a4..886ec03d 100644 --- a/lib/database.dart +++ b/lib/database.dart @@ -10,6 +10,7 @@ import 'objectbox.g.dart'; // created by `flutter pub run build_runner build` const SELECTED_SERVER = 'selected-server'; const USE_SPONSORBLOCK = 'use-sponsor-block'; +const SPONSOR_BLOCK_PREFIX='sponsor-block-'; const BROWSING_COUNTRY = 'browsing-country'; const DYNAMIC_THEME = 'dynamic-theme'; const USE_DASH = 'use-dash'; diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 30cb5a26..5134a6dc 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -400,6 +400,74 @@ "addRecommendedToQueue": "Auto-play recommended next", "@addRecommendedToQueue": { "description": "Switch when playing a video to automatically add the recommended videos to the video queue" + }, + "sponsorBlockSettingsQuickDescription": "Select which type of segments to skip", + "@sponsorBlockSettingsQuickDescription": { + "description": "Small description of what the sponsor block settings do" + }, + "sponsorBlockCategorySponsor": "Sponsor", + "@sponsorBlockCategorySponsor": { + "description": "Sponsor block 'Sponsor' Category" + }, + "sponsorBlockCategorySponsorDescription": "Paid promotion, paid referrals and direct advertisements. Not for self-promotion or free shoutouts to causes/creators/websites/products they like.", + "@sponsorBlockCategorySponsorDescription": { + "description": "Sponsor block 'Sponsor' Category description" + }, + "sponsorBlockCategoryUnpaidSelfPromo": "Unpaid/Self Promotion", + "@sponsorBlockCategoryUnpaidSelfPromo": { + "description": "Sponsor block 'Unpaid/Self promotion' Category" + }, + "sponsorBlockCategoryUnpaidSelfPromoDescription": "Similar to \"sponsor\" except for unpaid or self promotion. This includes sections about merchandise, donations, or information about who they collaborated ", + "@sponsorBlockCategoryUnpaidSelfPromoDescription": { + "description": "Sponsor block 'Unpaid/Self promotion' Category description" + }, + "sponsorBlockCategoryInteraction": "Interaction Reminder (Subscribe)", + "@sponsorBlockCategoryInteraction": { + "description": "Sponsor block 'Interaction' Category" + }, + "sponsorBlockCategoryInteractionDescription": "When there is a short reminder to like, subscribe or follow them in the middle of content. If it is long or about something specific, it should be under self promotion instead.", + "@sponsorBlockCategoryInteractionDescription": { + "description": "Sponsor block 'Interaction' Category description" + }, + "sponsorBlockCategoryIntro": "Intermission/Intro Animation", + "@sponsorBlockCategoryIntro": { + "description": "Sponsorblock 'Intro' Category" + }, + "sponsorBlockCategoryIntroDescription": "An interval without actual content. Could be a pause, static frame, repeating animation. This should not be used for transitions containing information.", + "@sponsorBlockCategoryIntroDescription": { + "description": "Sponsorblock 'Intro' Category description" + }, + "sponsorBlockCategoryOutro": "Endcards/Credits", + "@sponsorBlockCategoryOutro": { + "description": "Outro block 'Outro' Category" + }, + "sponsorBlockCategoryOutroDescription": "Credits or when the YouTube endcards appear. Not for conclusions with information.", + "@sponsorBlockCategoryOutroDescription": { + "description": "Outro block 'Outro' Category description" + }, + "sponsorBlockCategoryPreview": "Preview/Recap", + "@sponsorBlockCategoryPreview": { + "description": "Sponsorblock 'Preview' Category" + }, + "sponsorBlockCategoryPreviewDescription": "Collection of clips that show what is coming up in in this video or other videos in a series where all information is repeated later in the video", + "@sponsorBlockCategoryPreviewDescription": { + "description": "Sponsorblock 'Preview' Category description" + }, + "sponsorBlockCategoryFiller": "Filler Tangent/Jokes", + "@sponsorBlockCategoryFiller": { + "description": "Sponsorblock 'Filler' Category" + }, + "sponsorBlockCategoryFillerDescription": "Tangential scenes added only for filler or humor that are not required to understand the main content of the video. This should not include segments providing context or background details. This is a very aggressive category meant for when you aren''t in the mood for \"fun\".", + "@sponsorBlockCategoryFillerDescription": { + "description": "Sponsorblock 'Filler' Category description" + }, + "sponsorBlockCategoryMusicOffTopic": "Music: Non-Music Section", + "@sponsorBlockCategoryMusicOffTopic": { + "description": "Sponsorblock 'MusicOffTopic' Category" + }, + "sponsorBlockCategoryMusicOffTopicDescription": "An interval without actual content. Could be a pause, static frame, repeating animation. This should not be used for transitions containing information.", + "@sponsorBlockCategoryMusicOffTopicDescription": { + "description": "Only for use in music videos. This only should be used for sections of music videos that aren't already covered by another category." }, "useProxy": "Proxy videos", "@useProxy": { diff --git a/lib/models/sponsorSegment.dart b/lib/models/sponsorSegment.dart index 0ad2140e..89789830 100644 --- a/lib/models/sponsorSegment.dart +++ b/lib/models/sponsorSegment.dart @@ -1,3 +1,4 @@ +import 'package:invidious/models/sponsorSegmentTypes.dart'; import 'package:json_annotation/json_annotation.dart'; /* @@ -21,8 +22,9 @@ part 'sponsorSegment.g.dart'; class SponsorSegment{ String actionType; List segment; + SponsorSegmentType category; - SponsorSegment(this.actionType, this.segment); + SponsorSegment(this.actionType, this.segment, this.category); factory SponsorSegment.fromJson(Map json) => _$SponsorSegmentFromJson(json); diff --git a/lib/models/sponsorSegment.g.dart b/lib/models/sponsorSegment.g.dart index fb0764c6..2faade90 100644 --- a/lib/models/sponsorSegment.g.dart +++ b/lib/models/sponsorSegment.g.dart @@ -12,10 +12,23 @@ SponsorSegment _$SponsorSegmentFromJson(Map json) => (json['segment'] as List) .map((e) => (e as num).toDouble()) .toList(), + $enumDecode(_$SponsorSegmentTypeEnumMap, json['category']), ); Map _$SponsorSegmentToJson(SponsorSegment instance) => { 'actionType': instance.actionType, 'segment': instance.segment, + 'category': _$SponsorSegmentTypeEnumMap[instance.category]!, }; + +const _$SponsorSegmentTypeEnumMap = { + SponsorSegmentType.sponsor: 'sponsor', + SponsorSegmentType.selfpromo: 'selfpromo', + SponsorSegmentType.interaction: 'interaction', + SponsorSegmentType.intro: 'intro', + SponsorSegmentType.outro: 'outro', + SponsorSegmentType.preview: 'preview', + SponsorSegmentType.music_offtopic: 'music_offtopic', + SponsorSegmentType.filler: 'filler', +}; diff --git a/lib/models/sponsorSegmentTypes.dart b/lib/models/sponsorSegmentTypes.dart new file mode 100644 index 00000000..63a454ed --- /dev/null +++ b/lib/models/sponsorSegmentTypes.dart @@ -0,0 +1,57 @@ +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:invidious/database.dart'; + +enum SponsorSegmentType { + sponsor, + selfpromo, + interaction, + intro, + outro, + preview, + music_offtopic, + filler; + + String settingsName() => '$SPONSOR_BLOCK_PREFIX$name'; + + static String getLabel(SponsorSegmentType type, AppLocalizations locals) { + switch (type) { + case SponsorSegmentType.sponsor: + return locals.sponsorBlockCategorySponsor; + case SponsorSegmentType.selfpromo: + return locals.sponsorBlockCategoryUnpaidSelfPromo; + case SponsorSegmentType.interaction: + return locals.sponsorBlockCategoryInteraction; + case SponsorSegmentType.intro: + return locals.sponsorBlockCategoryIntro; + case SponsorSegmentType.outro: + return locals.sponsorBlockCategoryOutro; + case SponsorSegmentType.preview: + return locals.sponsorBlockCategoryPreview; + case SponsorSegmentType.music_offtopic: + return locals.sponsorBlockCategoryMusicOffTopic; + case SponsorSegmentType.filler: + return locals.sponsorBlockCategoryFiller; + } + } + + static String getDescription(SponsorSegmentType type, AppLocalizations locals) { + switch (type) { + case SponsorSegmentType.sponsor: + return locals.sponsorBlockCategorySponsorDescription; + case SponsorSegmentType.selfpromo: + return locals.sponsorBlockCategoryUnpaidSelfPromoDescription; + case SponsorSegmentType.interaction: + return locals.sponsorBlockCategoryInteractionDescription; + case SponsorSegmentType.intro: + return locals.sponsorBlockCategoryIntroDescription; + case SponsorSegmentType.outro: + return locals.sponsorBlockCategoryOutroDescription; + case SponsorSegmentType.preview: + return locals.sponsorBlockCategoryPreviewDescription; + case SponsorSegmentType.music_offtopic: + return locals.sponsorBlockCategoryMusicOffTopicDescription; + case SponsorSegmentType.filler: + return locals.sponsorBlockCategoryFillerDescription; + } + } +} diff --git a/lib/myRouteObserver.dart b/lib/myRouteObserver.dart index a9bcccd8..85f61a59 100644 --- a/lib/myRouteObserver.dart +++ b/lib/myRouteObserver.dart @@ -10,6 +10,7 @@ import 'globals.dart'; const RouteSettings ROUTE_SETTINGS = RouteSettings(name: 'settings'); const RouteSettings ROUTE_SETTINGS_MANAGE_SERVERS = RouteSettings(name: 'settings-manage-servers'); const RouteSettings ROUTE_SETTINGS_MANAGE_ONE_SERVER = RouteSettings(name: 'settings-manage-one-server'); +const RouteSettings ROUTE_SETTINGS_SPONSOR_BLOCK = RouteSettings(name: 'settings-sponsor-block'); const RouteSettings ROUTE_VIDEO = RouteSettings(name: 'video'); const RouteSettings ROUTE_CHANNEL = RouteSettings(name: 'channel'); const RouteSettings ROUTE_PLAYLIST_LIST = RouteSettings(name: 'playlist-list'); @@ -25,6 +26,7 @@ class MyRouteObserver extends RouteObserver> { case ROUTE_PLAYLIST: case ROUTE_SETTINGS_MANAGE_SERVERS: case ROUTE_SETTINGS_MANAGE_ONE_SERVER: + case ROUTE_SETTINGS_SPONSOR_BLOCK: case ROUTE_VIDEO: case ROUTE_PLAYLIST_LIST: case ROUTE_CHANNEL: @@ -42,7 +44,7 @@ class MyRouteObserver extends RouteObserver> { void didPush(Route route, Route? previousRoute) { super.didPush(route, previousRoute); if (route is PageRoute) { - if(previousRoute is PageRoute){ + if (previousRoute is PageRoute) { stopPlayingOnPop(route, previousRoute); } } diff --git a/lib/service.dart b/lib/service.dart index b7dcd977..cf2a123b 100644 --- a/lib/service.dart +++ b/lib/service.dart @@ -24,6 +24,7 @@ import 'models/channelVideos.dart'; import 'models/db/server.dart'; import 'models/invidiousPublicServer.dart'; import 'models/searchSuggestion.dart'; +import 'models/sponsorSegmentTypes.dart'; import 'models/subscription.dart'; import 'models/videoComments.dart'; @@ -82,7 +83,9 @@ class Service { Uri buildUrl(String baseUrl, {Map? pathParams, Map? query}) { try { - String url = '${db.getCurrentlySelectedServer().url}$baseUrl'; + String url = '${db + .getCurrentlySelectedServer() + .url}$baseUrl'; pathParams?.forEach((key, value) { url = url.replaceAll(key, value); @@ -147,7 +150,9 @@ class Service { String url = '$serverUrl/authorize_token?scopes=:feed,:subscriptions*,:playlists*&callback_url=clipious-auth://'; final result = await FlutterWebAuth.authenticate(url: url, callbackUrlScheme: 'clipious-auth'); - final token = Uri.parse(result).queryParameters['token']; + final token = Uri + .parse(result) + .queryParameters['token']; Server? server = db.getServer(serverUrl); @@ -176,7 +181,9 @@ class Service { } Future> getTrending({String? type}) async { - String countryCode = db.getSettings(BROWSING_COUNTRY)?.value ?? 'US'; + String countryCode = db + .getSettings(BROWSING_COUNTRY) + ?.value ?? 'US'; // parse.queryParameters['region'] = countryCode; Map? query = {'region': countryCode}; @@ -234,9 +241,15 @@ class Service { return UserFeed.fromJson(handleResponse(response)); } - Future> getSponsorSegments(String videoId) async { + Future> getSponsorSegments(String videoId, List categories) async { try { String url = GET_SPONSOR_SEGMENTS.replaceAll(":id", videoId); + + if (categories.isNotEmpty) { + url += '&categories=[${categories.map((e) => '"${e.name}"').join(",")}]'; + } + + log.info('Calling $url'); final response = await http.get(Uri.parse(url)); Iterable i = handleResponse(response); @@ -408,14 +421,18 @@ class Service { } Future pingServer(String url) async { - int start = DateTime.now().millisecondsSinceEpoch; + int start = DateTime + .now() + .millisecondsSinceEpoch; String fullUri = '$url${STATS}'; log.info('calling ${fullUri}'); final response = await http.get(Uri.parse(fullUri), headers: {'Content-Type': 'application/json; charset=utf-16'}); try { handleResponse(response); - var diff = DateTime.now().millisecondsSinceEpoch - start; + var diff = DateTime + .now() + .millisecondsSinceEpoch - start; return Duration(milliseconds: diff); } catch (err) { log.info(err); diff --git a/lib/views/settings.dart b/lib/views/settings.dart index a0e6a6a0..66699af6 100644 --- a/lib/views/settings.dart +++ b/lib/views/settings.dart @@ -4,6 +4,7 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:get/get.dart'; import 'package:invidious/myRouteObserver.dart'; import 'package:invidious/views/components/miniPlayerAware.dart'; +import 'package:invidious/views/settings/sponsorBlockSettings.dart'; import 'package:select_dialog/select_dialog.dart'; import 'package:settings_ui/settings_ui.dart'; @@ -27,6 +28,10 @@ class Settings extends StatelessWidget { Navigator.push(context, MaterialPageRoute(settings: ROUTE_SETTINGS_MANAGE_SERVERS, builder: (context) => const ManageServers())); } + openSponsorBlockSettings(BuildContext context) { + Navigator.push(context, MaterialPageRoute(settings: ROUTE_SETTINGS_SPONSOR_BLOCK, builder: (context) => const SponsorBlockSettings())); + } + searchCountry(BuildContext context, SettingsController controller) { var locals = AppLocalizations.of(context); SelectDialog.showModal( @@ -117,13 +122,10 @@ class Settings extends StatelessWidget { title: Text(locals.useProxy), description: Text(locals.useProxyDescription), ), - ]), - SettingsSection(title: const Text('SponsorBlock'), tiles: [ - SettingsTile.switchTile( - initialValue: _.sponsorBlock, - onToggle: _.toggleSponsorBlock, - title: Text(locals.useSponsorBlock), + SettingsTile.navigation( + title: Text('SponsorBlock'), description: Text(locals.sponsorBlockDescription), + onPressed: openSponsorBlockSettings, ) ]), SettingsSection( diff --git a/lib/views/settings/sponsorBlockSettings.dart b/lib/views/settings/sponsorBlockSettings.dart new file mode 100644 index 00000000..1c191f4d --- /dev/null +++ b/lib/views/settings/sponsorBlockSettings.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:get/get.dart'; +import 'package:invidious/controllers/sponsorBlockSettingsController.dart'; +import 'package:invidious/models/sponsorSegmentTypes.dart'; +import 'package:settings_ui/settings_ui.dart'; + +import '../components/miniPlayerAware.dart'; +import '../settings.dart'; + +class SponsorBlockSettings extends StatelessWidget { + const SponsorBlockSettings({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + var locals = AppLocalizations.of(context)!; + ColorScheme colorScheme = Theme.of(context).colorScheme; + SettingsThemeData theme = settingsTheme(colorScheme); + + return GetBuilder( + init: SponsorBlockSettingsController(), + global: false, + builder: (_) { + return MiniPlayerAware( + child: Scaffold( + appBar: AppBar( + scrolledUnderElevation: 0, + title: const Text('SponsorBlock'), + ), + backgroundColor: colorScheme.background, + body: SafeArea( + bottom: false, + child: SettingsList(lightTheme: theme, darkTheme: theme, sections: [ + SettingsSection( + title: Text(locals.sponsorBlockSettingsQuickDescription), + tiles: SponsorSegmentType.values + .map((t) => SettingsTile.switchTile( + initialValue: _.value(t), + onToggle: (bool value) => _.setValue(t, value), + title: Text(SponsorSegmentType.getLabel(t, locals)), + description: Text(SponsorSegmentType.getDescription(t, locals)), + )) + .toList()), + ]), + )), + ); + }, + ); + } +}